Skip to content

Commit ac50d8a

Browse files
committed
Refactor serialization and AUScanner for improved error handling and constants usage
- Replaced magic numbers and strings with constants for APP_VERSION and CACHE_VERSION in serialization logic. - Enhanced error handling by introducing custom exceptions (CacheCorruptedError, CacheVersionError, CacheWriteError) for better clarity and debugging. - Updated AUScanner to use PLATFORM_MACOS and PLUGIN_TYPE_AU constants, improving code readability and maintainability. - Improved logging for error scenarios to provide more informative feedback during plugin operations.
1 parent 853693e commit ac50d8a

3 files changed

Lines changed: 109 additions & 11 deletions

File tree

src/pedalboard_pluginary/retry.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""
2+
Retry logic for handling transient failures.
3+
"""
4+
5+
import functools
6+
import logging
7+
import time
8+
from typing import Any, Callable, Optional, Tuple, Type, TypeVar, Union
9+
10+
from .constants import MAX_SCAN_RETRIES, SCAN_RETRY_DELAY
11+
12+
logger = logging.getLogger(__name__)
13+
14+
F = TypeVar('F', bound=Callable[..., Any])
15+
16+
17+
def with_retry(
18+
exceptions: Union[Type[Exception], Tuple[Type[Exception], ...]],
19+
max_attempts: int = MAX_SCAN_RETRIES,
20+
delay: float = SCAN_RETRY_DELAY,
21+
backoff_factor: float = 2.0,
22+
max_delay: float = 60.0,
23+
) -> Callable[[F], F]:
24+
"""Decorator that retries a function on specified exceptions.
25+
26+
Args:
27+
exceptions: Exception or tuple of exceptions to catch and retry on.
28+
max_attempts: Maximum number of attempts (including the first).
29+
delay: Initial delay between retries in seconds.
30+
backoff_factor: Factor to multiply delay by after each failure.
31+
max_delay: Maximum delay between retries in seconds.
32+
33+
Returns:
34+
Decorated function that will retry on failure.
35+
"""
36+
def decorator(func: F) -> F:
37+
@functools.wraps(func)
38+
def wrapper(*args: Any, **kwargs: Any) -> Any:
39+
current_delay = delay
40+
last_exception: Optional[Exception] = None
41+
42+
for attempt in range(max_attempts):
43+
try:
44+
return func(*args, **kwargs)
45+
except exceptions as e:
46+
last_exception = e
47+
if attempt == max_attempts - 1:
48+
# Last attempt, re-raise
49+
logger.warning(
50+
f"{func.__name__} failed after {max_attempts} attempts: {e}"
51+
)
52+
raise
53+
54+
logger.info(
55+
f"{func.__name__} failed (attempt {attempt + 1}/{max_attempts}): {e}. "
56+
f"Retrying in {current_delay:.1f}s..."
57+
)
58+
time.sleep(current_delay)
59+
60+
# Exponential backoff with max delay
61+
current_delay = min(current_delay * backoff_factor, max_delay)
62+
63+
# This should never be reached, but just in case
64+
if last_exception:
65+
raise last_exception
66+
else:
67+
raise RuntimeError(f"{func.__name__} failed with unknown error")
68+
69+
return wrapper # type: ignore[return-value]
70+
71+
return decorator
72+
73+
74+
def with_timeout(timeout: float) -> Callable[[F], F]:
75+
"""Decorator that adds a timeout to a function.
76+
77+
Note: This is a placeholder for future implementation.
78+
Proper timeout handling requires different approaches for
79+
synchronous vs asynchronous functions.
80+
81+
Args:
82+
timeout: Timeout in seconds.
83+
84+
Returns:
85+
Decorated function.
86+
"""
87+
def decorator(func: F) -> F:
88+
@functools.wraps(func)
89+
def wrapper(*args: Any, **kwargs: Any) -> Any:
90+
# TODO: Implement proper timeout handling
91+
# For now, just pass through
92+
return func(*args, **kwargs)
93+
94+
return wrapper # type: ignore[return-value]
95+
96+
return decorator

src/pedalboard_pluginary/scanners/au_scanner.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,19 @@ def __init__(
2929
):
3030
"""Initialize the AU scanner with optional ignore paths and specific paths."""
3131
super().__init__(ignore_paths, specific_paths)
32-
self._is_macos = platform.system() == "Darwin"
32+
self._is_macos = platform.system() == PLATFORM_MACOS
3333
if not self._is_macos:
3434
logger.info("AU scanning is only available on macOS.")
3535

3636
@property
3737
def plugin_type(self) -> str:
3838
"""Return the plugin type this scanner handles."""
39-
return "aufx"
39+
return PLUGIN_TYPE_AU
4040

4141
@property
4242
def supported_extensions(self) -> List[str]:
4343
"""Return list of file extensions this scanner supports."""
44-
return [".component"]
44+
return [AU_EXTENSION]
4545

4646
def _get_au_plugin_locations(self) -> List[Path]:
4747
"""Get standard AU plugin locations on macOS."""
@@ -184,6 +184,9 @@ def scan_plugin(self, path: Path) -> Optional[PluginInfo]:
184184
logger.info(f"Successfully scanned AU plugin: {display_name}")
185185
return plugin_info
186186

187+
except PluginLoadError:
188+
# Re-raise our custom exceptions
189+
raise
187190
except Exception as e:
188191
logger.error(f"Failed to scan AU plugin {path} with pedalboard: {e}")
189192
# Fall back to basic info extraction from auval

src/pedalboard_pluginary/serialization.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from pathlib import Path
99
from typing import Any, Dict, Optional
1010

11+
from .constants import APP_VERSION, CACHE_VERSION
12+
from .exceptions import CacheCorruptedError, CacheVersionError, CacheWriteError
1113
from .models import PluginInfo, PluginParameter
1214
from .types import (
1315
CacheData,
@@ -21,9 +23,6 @@
2123

2224
logger = logging.getLogger(__name__)
2325

24-
# Version for cache format
25-
CACHE_VERSION = "2.0.0"
26-
2726

2827
class PluginSerializer:
2928
"""Handles serialization and deserialization of plugin data."""
@@ -142,7 +141,7 @@ def create_cache_metadata(cls, plugin_count: int) -> CacheMetadata:
142141
"created_at": now,
143142
"updated_at": now,
144143
"plugin_count": plugin_count,
145-
"scanner_version": "0.1.0", # TODO: Get from package version
144+
"scanner_version": APP_VERSION,
146145
}
147146

148147
@classmethod
@@ -177,7 +176,7 @@ def save_plugins(cls, plugins: Dict[str, PluginInfo], path: Path) -> None:
177176
logger.info(f"Saved {len(plugins_dict)} plugins to {path}")
178177
except Exception as e:
179178
logger.error(f"Failed to save plugins to {path}: {e}")
180-
raise
179+
raise CacheWriteError(str(path), str(e))
181180

182181
@classmethod
183182
def load_plugins(cls, path: Path) -> Dict[str, PluginInfo]:
@@ -198,10 +197,10 @@ def load_plugins(cls, path: Path) -> Dict[str, PluginInfo]:
198197
data = json.load(f)
199198
except json.JSONDecodeError as e:
200199
logger.error(f"Invalid JSON in cache file {path}: {e}")
201-
return {}
200+
raise CacheCorruptedError(str(path), f"JSON decode error: {e}")
202201
except Exception as e:
203202
logger.error(f"Failed to read cache file {path}: {e}")
204-
return {}
203+
raise CacheCorruptedError(str(path), str(e))
205204

206205
# Handle both old format (direct plugin dict) and new format (with metadata)
207206
if isinstance(data, dict) and "metadata" in data and "plugins" in data:
@@ -211,7 +210,7 @@ def load_plugins(cls, path: Path) -> Dict[str, PluginInfo]:
211210

212211
if cache_version != CACHE_VERSION:
213212
logger.warning(f"Cache version mismatch: expected {CACHE_VERSION}, got {cache_version}")
214-
# TODO: Implement cache migration if needed
213+
raise CacheVersionError(CACHE_VERSION, cache_version, str(path))
215214

216215
plugins_data = data.get("plugins", {})
217216
else:

0 commit comments

Comments
 (0)