459 lines
16 KiB
Python
459 lines
16 KiB
Python
"""
|
|
error_handling.py
|
|
Description: Handles error recovery strategies for metadata operations.
|
|
Author: Eric Hiss (GitHub: EricRollei)
|
|
Contact: [eric@historic.camera, eric@rollei.us]
|
|
Version: 1.0.0
|
|
Date: [March 2025]
|
|
License: Dual License (Non-Commercial and Commercial Use)
|
|
Copyright (c) 2025 Eric Hiss. All rights reserved.
|
|
|
|
Dual License:
|
|
1. Non-Commercial Use: This software is licensed under the terms of the
|
|
Creative Commons Attribution-NonCommercial 4.0 International License.
|
|
To view a copy of this license, visit http://creativecommons.org/licenses/by-nc/4.0/
|
|
|
|
2. Commercial Use: For commercial use, a separate license is required.
|
|
Please contact Eric Hiss at [eric@historic.camera, eric@rollei.us] for licensing options.
|
|
|
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
|
|
Dependencies:
|
|
This code depends on several third-party libraries, each with its own license:
|
|
|
|
"""
|
|
# Metadata_system/src/eric_metadata/utils/error_handling.py
|
|
from typing import Dict, Any, Callable, Optional
|
|
import os
|
|
import json
|
|
import subprocess
|
|
import traceback
|
|
|
|
class ErrorRecovery:
|
|
"""Strategies for recovering from metadata operations errors"""
|
|
|
|
@staticmethod
|
|
def recover_write_error(handler, context: Dict[str, Any]) -> bool:
|
|
"""
|
|
Recover from write errors
|
|
|
|
Args:
|
|
handler: The handler that encountered the error
|
|
context: Error context including filepath, metadata, error_type
|
|
|
|
Returns:
|
|
bool: True if recovery succeeded
|
|
"""
|
|
filepath = context.get('filepath')
|
|
metadata = context.get('metadata')
|
|
|
|
if not filepath or not metadata:
|
|
return False
|
|
|
|
# Try different approaches based on error type
|
|
error_type = context.get('error_type')
|
|
|
|
if error_type == 'PyExiv2Error':
|
|
# Try ExifTool as fallback
|
|
return ErrorRecovery._write_with_exiftool(filepath, metadata)
|
|
|
|
elif error_type == 'XMLError':
|
|
# Try simplified XML structure
|
|
return ErrorRecovery._write_simplified_xml(filepath, metadata)
|
|
|
|
elif error_type == 'IOError':
|
|
# Try writing to alternate location
|
|
return ErrorRecovery._write_to_backup(filepath, metadata)
|
|
|
|
# Default strategy: Write to JSON backup
|
|
return ErrorRecovery._write_to_json_backup(filepath, metadata)
|
|
|
|
@staticmethod
|
|
def recover_read_error(handler, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Recover from read errors
|
|
|
|
Args:
|
|
handler: The handler that encountered the error
|
|
context: Error context including filepath, error_type
|
|
|
|
Returns:
|
|
dict: Recovered metadata or empty dict
|
|
"""
|
|
filepath = context.get('filepath')
|
|
|
|
if not filepath:
|
|
return {}
|
|
|
|
# Try different approaches based on error type
|
|
error_type = context.get('error_type')
|
|
|
|
if error_type == 'PyExiv2Error':
|
|
# Try ExifTool as fallback
|
|
return ErrorRecovery._read_with_exiftool(filepath)
|
|
|
|
elif error_type == 'XMLError':
|
|
# Try parsing with more lenient XML parser
|
|
return ErrorRecovery._read_with_lenient_parser(filepath)
|
|
|
|
# Check for backup files
|
|
backup_path = f"{os.path.splitext(filepath)[0]}.json"
|
|
if os.path.exists(backup_path):
|
|
try:
|
|
with open(backup_path, 'r') as f:
|
|
return json.load(f)
|
|
except:
|
|
pass
|
|
|
|
return {}
|
|
|
|
@staticmethod
|
|
def _write_with_exiftool(filepath: str, metadata: Dict[str, Any]) -> bool:
|
|
"""
|
|
Write metadata using ExifTool
|
|
|
|
Args:
|
|
filepath: Path to the file
|
|
metadata: Metadata to write
|
|
|
|
Returns:
|
|
bool: True if successful
|
|
"""
|
|
try:
|
|
# Create a temporary JSON file with the metadata
|
|
temp_json = f"{filepath}.temp.json"
|
|
with open(temp_json, 'w') as f:
|
|
json.dump(metadata, f)
|
|
|
|
# Use ExifTool to write the metadata
|
|
cmd = [
|
|
'exiftool',
|
|
'-json',
|
|
f"{temp_json}",
|
|
'-overwrite_original',
|
|
filepath
|
|
]
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
|
|
# Clean up temp file
|
|
if os.path.exists(temp_json):
|
|
os.remove(temp_json)
|
|
|
|
return result.returncode == 0
|
|
|
|
except Exception as e:
|
|
print(f"ExifTool write failed: {e}")
|
|
traceback.print_exc()
|
|
return False
|
|
|
|
@staticmethod
|
|
def _read_with_exiftool(filepath: str) -> Dict[str, Any]:
|
|
"""
|
|
Read metadata using ExifTool
|
|
|
|
Args:
|
|
filepath: Path to the file
|
|
|
|
Returns:
|
|
dict: Metadata read from the file
|
|
"""
|
|
try:
|
|
# Use ExifTool to read the metadata
|
|
cmd = [
|
|
'exiftool',
|
|
'-json',
|
|
'-a', # Process duplicate tags
|
|
'-G', # Show group names
|
|
filepath
|
|
]
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
return {}
|
|
|
|
# Parse JSON output
|
|
try:
|
|
data = json.loads(result.stdout)
|
|
if isinstance(data, list) and len(data) > 0:
|
|
return ErrorRecovery._convert_exiftool_data(data[0])
|
|
return {}
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
|
|
except Exception:
|
|
return {}
|
|
|
|
@staticmethod
|
|
def _convert_exiftool_data(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Convert ExifTool data format to our standard format
|
|
|
|
Args:
|
|
data: ExifTool data
|
|
|
|
Returns:
|
|
dict: Converted metadata
|
|
"""
|
|
result = {
|
|
'basic': {},
|
|
'analysis': {},
|
|
'ai_info': {}
|
|
}
|
|
|
|
# Process different tag groups
|
|
for key, value in data.items():
|
|
# XMP tags (most important for us)
|
|
if key.startswith('XMP:'):
|
|
tag_name = key[4:]
|
|
|
|
if tag_name == 'Title':
|
|
result['basic']['title'] = value
|
|
elif tag_name == 'Subject':
|
|
result['basic']['keywords'] = value if isinstance(value, list) else [value]
|
|
elif tag_name == 'Description':
|
|
result['basic']['description'] = value
|
|
elif tag_name == 'Rating':
|
|
result['basic']['rating'] = int(value)
|
|
|
|
# Look for custom namespaces
|
|
elif key.startswith('XMP-eiqa:'):
|
|
tag_name = key[9:]
|
|
parts = tag_name.split(':')
|
|
|
|
if len(parts) == 1:
|
|
# Top-level EIQA tag
|
|
if parts[0] not in result['analysis']:
|
|
result['analysis'][parts[0]] = {}
|
|
result['analysis'][parts[0]] = value
|
|
elif len(parts) >= 2:
|
|
# Nested EIQA tag
|
|
if parts[0] not in result['analysis']:
|
|
result['analysis'][parts[0]] = {}
|
|
|
|
current = result['analysis'][parts[0]]
|
|
for part in parts[1:-1]:
|
|
current = current.setdefault(part, {})
|
|
current[parts[-1]] = value
|
|
|
|
elif key.startswith('XMP-ai:'):
|
|
tag_name = key[7:]
|
|
parts = tag_name.split(':')
|
|
|
|
if len(parts) == 1:
|
|
# Top-level AI tag
|
|
if parts[0] not in result['ai_info']:
|
|
result['ai_info'][parts[0]] = {}
|
|
result['ai_info'][parts[0]] = value
|
|
elif len(parts) >= 2:
|
|
# Nested AI tag
|
|
if parts[0] not in result['ai_info']:
|
|
result['ai_info'][parts[0]] = {}
|
|
|
|
current = result['ai_info'][parts[0]]
|
|
for part in parts[1:-1]:
|
|
current = current.setdefault(part, {})
|
|
current[parts[-1]] = value
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def _write_simplified_xml(filepath: str, metadata: Dict[str, Any]) -> bool:
|
|
"""
|
|
Write simplified XML structure when complex structure fails
|
|
|
|
Args:
|
|
filepath: Path to the file
|
|
metadata: Metadata to write
|
|
|
|
Returns:
|
|
bool: True if successful
|
|
"""
|
|
try:
|
|
import xml.etree.ElementTree as ET
|
|
from ..utils.namespace import NamespaceManager
|
|
|
|
# Get the base path for XMP sidecar
|
|
base_path, _ = os.path.splitext(filepath)
|
|
xmp_path = f"{base_path}.xmp"
|
|
|
|
# Create a simplified XMP structure
|
|
root = ET.Element("{adobe:ns:meta/}xmpmeta")
|
|
|
|
# Add namespaces
|
|
for prefix, uri in NamespaceManager.NAMESPACES.items():
|
|
root.set(f"xmlns:{prefix}", uri)
|
|
|
|
# Create RDF element
|
|
rdf = ET.SubElement(root, f"{{{NamespaceManager.NAMESPACES['rdf']}}}RDF")
|
|
|
|
# Create Description element
|
|
desc = ET.SubElement(rdf, f"{{{NamespaceManager.NAMESPACES['rdf']}}}Description")
|
|
desc.set(f"{{{NamespaceManager.NAMESPACES['rdf']}}}about", "")
|
|
|
|
# Add basic metadata as attributes
|
|
if 'basic' in metadata:
|
|
if 'title' in metadata['basic']:
|
|
desc.set(f"{{{NamespaceManager.NAMESPACES['dc']}}}title", str(metadata['basic']['title']))
|
|
if 'description' in metadata['basic']:
|
|
desc.set(f"{{{NamespaceManager.NAMESPACES['dc']}}}description", str(metadata['basic']['description']))
|
|
|
|
# Add analysis data (flattened)
|
|
if 'analysis' in metadata:
|
|
for section, data in metadata['analysis'].items():
|
|
if isinstance(data, dict):
|
|
for key, value in data.items():
|
|
if isinstance(value, (str, int, float, bool)):
|
|
desc.set(f"{{{NamespaceManager.NAMESPACES['eiqa']}}}{section}.{key}", str(value))
|
|
|
|
# Add AI data (flattened)
|
|
if 'ai_info' in metadata:
|
|
for section, data in metadata['ai_info'].items():
|
|
if isinstance(data, dict):
|
|
for key, value in data.items():
|
|
if isinstance(value, (str, int, float, bool)):
|
|
desc.set(f"{{{NamespaceManager.NAMESPACES['ai']}}}{section}.{key}", str(value))
|
|
|
|
# Write to file
|
|
tree = ET.ElementTree(root)
|
|
tree.write(xmp_path, encoding='utf-8', xml_declaration=True)
|
|
|
|
return True
|
|
|
|
except Exception:
|
|
return False
|
|
|
|
@staticmethod
|
|
def _read_with_lenient_parser(filepath: str) -> Dict[str, Any]:
|
|
"""
|
|
Read with a more lenient XML parser
|
|
|
|
Args:
|
|
filepath: Path to the file
|
|
|
|
Returns:
|
|
dict: Metadata read from the file
|
|
"""
|
|
try:
|
|
# Handle XMP sidecar file
|
|
if filepath.endswith('.xmp'):
|
|
xmp_path = filepath
|
|
else:
|
|
base_path, _ = os.path.splitext(filepath)
|
|
xmp_path = f"{base_path}.xmp"
|
|
|
|
if not os.path.exists(xmp_path):
|
|
return {}
|
|
|
|
# Read the file content
|
|
with open(xmp_path, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
# Use a simple regex-based parser
|
|
result = {
|
|
'basic': {},
|
|
'analysis': {},
|
|
'ai_info': {}
|
|
}
|
|
|
|
# Extract basic metadata
|
|
title_match = re.search(r'dc:title="([^"]*)"', content)
|
|
if title_match:
|
|
result['basic']['title'] = title_match.group(1)
|
|
|
|
desc_match = re.search(r'dc:description="([^"]*)"', content)
|
|
if desc_match:
|
|
result['basic']['description'] = desc_match.group(1)
|
|
|
|
# Extract EIQA data
|
|
for match in re.finditer(r'eiqa:([a-zA-Z0-9_.]+)="([^"]*)"', content):
|
|
path = match.group(1).split('.')
|
|
value = match.group(2)
|
|
|
|
if len(path) == 1:
|
|
result['analysis'][path[0]] = value
|
|
elif len(path) >= 2:
|
|
if path[0] not in result['analysis']:
|
|
result['analysis'][path[0]] = {}
|
|
|
|
current = result['analysis'][path[0]]
|
|
for part in path[1:-1]:
|
|
current = current.setdefault(part, {})
|
|
current[path[-1]] = value
|
|
|
|
# Extract AI data
|
|
for match in re.finditer(r'ai:([a-zA-Z0-9_.]+)="([^"]*)"', content):
|
|
path = match.group(1).split('.')
|
|
value = match.group(2)
|
|
|
|
if len(path) == 1:
|
|
result['ai_info'][path[0]] = value
|
|
elif len(path) >= 2:
|
|
if path[0] not in result['ai_info']:
|
|
result['ai_info'][path[0]] = {}
|
|
|
|
current = result['ai_info'][path[0]]
|
|
for part in path[1:-1]:
|
|
current = current.setdefault(part, {})
|
|
current[path[-1]] = value
|
|
|
|
return result
|
|
|
|
except Exception:
|
|
return {}
|
|
|
|
@staticmethod
|
|
def _write_to_backup(filepath: str, metadata: Dict[str, Any]) -> bool:
|
|
"""
|
|
Write metadata to a backup file
|
|
|
|
Args:
|
|
filepath: Path to the file
|
|
metadata: Metadata to write
|
|
|
|
Returns:
|
|
bool: True if successful
|
|
"""
|
|
try:
|
|
# Create backup directory if it doesn't exist
|
|
backup_dir = os.path.join(os.path.dirname(filepath), '.metadata_backup')
|
|
os.makedirs(backup_dir, exist_ok=True)
|
|
|
|
# Create backup filepath
|
|
filename = os.path.basename(filepath)
|
|
backup_path = os.path.join(backup_dir, f"{filename}.xmp")
|
|
|
|
# Write XMP sidecar to backup location
|
|
return ErrorRecovery._write_simplified_xml(backup_path, metadata)
|
|
|
|
except Exception:
|
|
# Last resort: write to JSON backup
|
|
return ErrorRecovery._write_to_json_backup(filepath, metadata)
|
|
|
|
@staticmethod
|
|
def _write_to_json_backup(filepath: str, metadata: Dict[str, Any]) -> bool:
|
|
"""
|
|
Write metadata to a JSON backup file
|
|
|
|
Args:
|
|
filepath: Path to the file
|
|
metadata: Metadata to write
|
|
|
|
Returns:
|
|
bool: True if successful
|
|
"""
|
|
try:
|
|
# Create backup filepath
|
|
base_path, _ = os.path.splitext(filepath)
|
|
backup_path = f"{base_path}.metadata.json"
|
|
|
|
# Write JSON
|
|
with open(backup_path, 'w', encoding='utf-8') as f:
|
|
json.dump(metadata, f, indent=2)
|
|
|
|
return True
|
|
|
|
except Exception:
|
|
return False |