Files

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