polystore 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
polystore/__init__.py ADDED
@@ -0,0 +1,80 @@
1
+ """
2
+ Polystore - Framework-agnostic multi-backend storage abstraction.
3
+
4
+ Provides pluggable storage backends with multi-framework I/O support for
5
+ NumPy, PyTorch, JAX, TensorFlow, CuPy, and Zarr.
6
+ """
7
+
8
+ __version__ = "0.1.2"
9
+
10
+ # Core abstractions
11
+ from .base import (
12
+ DataSink,
13
+ DataSource,
14
+ StorageBackend,
15
+ VirtualBackend,
16
+ ReadOnlyBackend,
17
+ )
18
+
19
+ # Concrete backends
20
+ from .memory import MemoryBackend
21
+ from .disk import DiskBackend
22
+
23
+ # Optional backends
24
+ try:
25
+ from .zarr import ZarrBackend
26
+ except ImportError:
27
+ ZarrBackend = None
28
+
29
+ # File manager
30
+ from .filemanager import FileManager
31
+
32
+ # Registry
33
+ from .backend_registry import BackendRegistry, create_storage_registry
34
+
35
+ # Atomic operations
36
+ from .atomic import atomic_write, atomic_write_json
37
+
38
+ # Exceptions
39
+ from .exceptions import (
40
+ StorageError,
41
+ StorageResolutionError,
42
+ BackendNotFoundError,
43
+ UnsupportedFormatError,
44
+ )
45
+
46
+ # Streaming (optional)
47
+ try:
48
+ from .streaming import StreamingBackend
49
+ except ImportError:
50
+ StreamingBackend = None
51
+
52
+ __all__ = [
53
+ # Version
54
+ "__version__",
55
+ # Core abstractions
56
+ "DataSink",
57
+ "DataSource",
58
+ "StorageBackend",
59
+ "VirtualBackend",
60
+ "ReadOnlyBackend",
61
+ # Backends
62
+ "MemoryBackend",
63
+ "DiskBackend",
64
+ "ZarrBackend",
65
+ # File manager
66
+ "FileManager",
67
+ # Registry
68
+ "BackendRegistry",
69
+ # Atomic operations
70
+ "atomic_write",
71
+ "atomic_write_json",
72
+ # Exceptions
73
+ "StorageError",
74
+ "StorageResolutionError",
75
+ "BackendNotFoundError",
76
+ "UnsupportedFormatError",
77
+ # Streaming
78
+ "StreamingBackend",
79
+ ]
80
+
polystore/atomic.py ADDED
@@ -0,0 +1,249 @@
1
+ """
2
+ Atomic file operations with locking for OpenHCS.
3
+
4
+ Provides utilities for atomic read-modify-write operations with file locking
5
+ to prevent concurrency issues in multiprocessing environments.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ import tempfile
12
+ import time
13
+ from contextlib import contextmanager
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any, Callable, Dict, Optional, TypeVar, Union
17
+
18
+ # Cross-platform file locking
19
+ try:
20
+ import fcntl
21
+ FCNTL_AVAILABLE = True
22
+ except ImportError:
23
+ # Windows compatibility - use portalocker
24
+ import portalocker
25
+ FCNTL_AVAILABLE = False
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ T = TypeVar('T')
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class LockConfig:
34
+ """Configuration constants for file locking operations."""
35
+ DEFAULT_TIMEOUT: float = 30.0
36
+ DEFAULT_POLL_INTERVAL: float = 0.1
37
+ LOCK_SUFFIX: str = '.lock'
38
+ TEMP_PREFIX: str = '.tmp'
39
+ JSON_INDENT: int = 2
40
+
41
+
42
+ LOCK_CONFIG = LockConfig()
43
+
44
+
45
+ class FileLockError(Exception):
46
+ """Raised when file locking operations fail."""
47
+ pass
48
+
49
+
50
+ class FileLockTimeoutError(FileLockError):
51
+ """Raised when file lock acquisition times out."""
52
+ pass
53
+
54
+
55
+ @contextmanager
56
+ def file_lock(
57
+ lock_path: Union[str, Path],
58
+ timeout: float = LOCK_CONFIG.DEFAULT_TIMEOUT,
59
+ poll_interval: float = LOCK_CONFIG.DEFAULT_POLL_INTERVAL
60
+ ):
61
+ """Context manager for exclusive file locking."""
62
+ lock_path = Path(lock_path)
63
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
64
+
65
+ lock_fd = None
66
+ try:
67
+ lock_fd = _acquire_lock_with_timeout(lock_path, timeout, poll_interval)
68
+ yield
69
+ except FileLockTimeoutError:
70
+ raise
71
+ except Exception as e:
72
+ raise FileLockError(f"File lock operation failed for {lock_path}: {e}") from e
73
+ finally:
74
+ _cleanup_lock(lock_fd, lock_path)
75
+
76
+
77
+ def _acquire_lock_with_timeout(lock_path: Path, timeout: float, poll_interval: float) -> int:
78
+ """Acquire file lock with timeout and return file descriptor."""
79
+ deadline = time.time() + timeout
80
+
81
+ while time.time() < deadline:
82
+ if lock_fd := _try_acquire_lock(lock_path):
83
+ return lock_fd
84
+ time.sleep(poll_interval)
85
+
86
+ raise FileLockTimeoutError(f"Failed to acquire lock {lock_path} within {timeout}s")
87
+
88
+
89
+ def _try_acquire_lock(lock_path: Path) -> Optional[int]:
90
+ """Try to acquire lock once, return fd or None."""
91
+ try:
92
+ lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_WRONLY | os.O_TRUNC)
93
+ if FCNTL_AVAILABLE:
94
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
95
+ else:
96
+ # Windows: use portalocker
97
+ portalocker.lock(lock_fd, portalocker.LOCK_EX | portalocker.LOCK_NB)
98
+ logger.debug(f"Acquired file lock: {lock_path}")
99
+ return lock_fd
100
+ except (OSError, IOError):
101
+ return None
102
+
103
+
104
+ def _cleanup_lock(lock_fd: Optional[int], lock_path: Path) -> None:
105
+ """Clean up file lock resources."""
106
+ if lock_fd is not None:
107
+ try:
108
+ if FCNTL_AVAILABLE:
109
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
110
+ else:
111
+ # Windows: use portalocker
112
+ portalocker.unlock(lock_fd)
113
+ os.close(lock_fd)
114
+ logger.debug(f"Released file lock: {lock_path}")
115
+ except Exception as e:
116
+ logger.warning(f"Error releasing lock {lock_path}: {e}")
117
+
118
+ if lock_path.exists():
119
+ try:
120
+ lock_path.unlink()
121
+ except Exception as e:
122
+ logger.warning(f"Error removing lock file {lock_path}: {e}")
123
+
124
+
125
+ @contextmanager
126
+ def atomic_write(file_path: Union[str, Path], mode: str = 'w', ensure_directory: bool = True):
127
+ """
128
+ Context manager for atomic file writes.
129
+
130
+ Writes to a temporary file first, then renames to the target path.
131
+ This ensures the operation is atomic - either fully succeeds or fails
132
+ without leaving partial writes.
133
+
134
+ Args:
135
+ file_path: Target file path
136
+ mode: File mode ('w' for text, 'wb' for binary)
137
+ ensure_directory: Create parent directory if it doesn't exist
138
+
139
+ Example:
140
+ with atomic_write("output.txt") as f:
141
+ f.write("data")
142
+ """
143
+ file_path = Path(file_path)
144
+
145
+ if ensure_directory:
146
+ file_path.parent.mkdir(parents=True, exist_ok=True)
147
+
148
+ # Create temporary file in same directory
149
+ with tempfile.NamedTemporaryFile(
150
+ mode=mode,
151
+ dir=file_path.parent,
152
+ prefix=f"{LOCK_CONFIG.TEMP_PREFIX}{file_path.name}",
153
+ delete=False
154
+ ) as tmp_file:
155
+ tmp_path = tmp_file.name
156
+ try:
157
+ yield tmp_file
158
+ tmp_file.flush()
159
+ os.fsync(tmp_file.fileno())
160
+ except Exception:
161
+ # Clean up temp file on error
162
+ try:
163
+ os.unlink(tmp_path)
164
+ except Exception:
165
+ pass
166
+ raise
167
+
168
+ # Atomically replace target file
169
+ try:
170
+ os.replace(tmp_path, str(file_path))
171
+ logger.debug(f"Atomically wrote to {file_path}")
172
+ except Exception as e:
173
+ try:
174
+ os.unlink(tmp_path)
175
+ except Exception:
176
+ pass
177
+ raise FileLockError(f"Atomic write failed for {file_path}: {e}") from e
178
+
179
+
180
+ def atomic_write_json(
181
+ file_path: Union[str, Path],
182
+ data: Dict[str, Any],
183
+ indent: int = LOCK_CONFIG.JSON_INDENT,
184
+ ensure_directory: bool = True
185
+ ) -> None:
186
+ """Atomically write JSON data to file using temporary file + rename."""
187
+ file_path = Path(file_path)
188
+
189
+ if ensure_directory:
190
+ file_path.parent.mkdir(parents=True, exist_ok=True)
191
+
192
+ try:
193
+ tmp_path = _write_to_temp_file(file_path, data, indent)
194
+ # Use os.replace() instead of os.rename() for atomic replacement on all platforms
195
+ # os.rename() fails on Windows if destination exists, os.replace() works on both Unix and Windows
196
+ os.replace(tmp_path, str(file_path))
197
+ logger.debug(f"Atomically wrote JSON to {file_path}")
198
+ except Exception as e:
199
+ raise FileLockError(f"Atomic JSON write failed for {file_path}: {e}") from e
200
+
201
+
202
+ def _write_to_temp_file(file_path: Path, data: Dict[str, Any], indent: int) -> str:
203
+ """Write data to temporary file and return path."""
204
+ with tempfile.NamedTemporaryFile(
205
+ mode='w',
206
+ dir=file_path.parent,
207
+ prefix=f"{LOCK_CONFIG.TEMP_PREFIX}{file_path.name}",
208
+ suffix='.json',
209
+ delete=False
210
+ ) as tmp_file:
211
+ json.dump(data, tmp_file, indent=indent)
212
+ tmp_file.flush()
213
+ os.fsync(tmp_file.fileno())
214
+ return tmp_file.name
215
+
216
+
217
+ def atomic_update_json(
218
+ file_path: Union[str, Path],
219
+ update_func: Callable[[Optional[Dict[str, Any]]], Dict[str, Any]],
220
+ lock_timeout: float = LOCK_CONFIG.DEFAULT_TIMEOUT,
221
+ default_data: Optional[Dict[str, Any]] = None
222
+ ) -> None:
223
+ """Atomically update JSON file using read-modify-write with file locking."""
224
+ file_path = Path(file_path)
225
+ lock_path = file_path.with_suffix(f'{file_path.suffix}{LOCK_CONFIG.LOCK_SUFFIX}')
226
+
227
+ with file_lock(lock_path, timeout=lock_timeout):
228
+ current_data = _read_json_or_default(file_path, default_data)
229
+
230
+ try:
231
+ updated_data = update_func(current_data)
232
+ except Exception as e:
233
+ raise FileLockError(f"Update function failed for {file_path}: {e}") from e
234
+
235
+ atomic_write_json(file_path, updated_data)
236
+ logger.debug(f"Atomically updated JSON file: {file_path}")
237
+
238
+
239
+ def _read_json_or_default(file_path: Path, default_data: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
240
+ """Read JSON file or return default data if file doesn't exist or is invalid."""
241
+ if not file_path.exists():
242
+ return default_data
243
+
244
+ try:
245
+ with open(file_path, 'r') as f:
246
+ return json.load(f)
247
+ except (json.JSONDecodeError, IOError) as e:
248
+ logger.warning(f"Failed to read {file_path}, using default: {e}")
249
+ return default_data
@@ -0,0 +1,179 @@
1
+ """
2
+ Storage backend metaclass registration system.
3
+
4
+ Backends are automatically discovered and registered when their classes are defined.
5
+ """
6
+
7
+ import logging
8
+ from typing import Dict
9
+ from .base import BackendBase, DataSink
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ _backend_instances: Dict[str, DataSink] = {}
14
+
15
+
16
+ def _get_storage_backends() -> Dict:
17
+ """Get the storage backends registry, ensuring it's initialized."""
18
+ # Import backends to trigger registration
19
+ from . import memory, disk
20
+ try:
21
+ from . import zarr
22
+ except ImportError:
23
+ pass # Zarr not available
24
+
25
+ # Registry auto-created by AutoRegisterMeta on BackendBase
26
+ return BackendBase.__registry__
27
+
28
+
29
+ # Lazy access to registry
30
+ STORAGE_BACKENDS = None
31
+
32
+
33
+ def get_backend_instance(backend_type: str) -> DataSink:
34
+ """
35
+ Get backend instance by type with lazy instantiation.
36
+
37
+ Args:
38
+ backend_type: Backend type identifier (e.g., 'disk', 'memory')
39
+
40
+ Returns:
41
+ Backend instance
42
+
43
+ Raises:
44
+ KeyError: If backend type not registered
45
+ RuntimeError: If backend instantiation fails
46
+ """
47
+ backend_type = backend_type.lower()
48
+
49
+ # Return cached instance if available
50
+ if backend_type in _backend_instances:
51
+ return _backend_instances[backend_type]
52
+
53
+ # Get backend class from registry
54
+ storage_backends = _get_storage_backends()
55
+ if backend_type not in storage_backends:
56
+ raise KeyError(f"Backend type '{backend_type}' not registered. "
57
+ f"Available backends: {list(storage_backends.keys())}")
58
+
59
+ backend_class = storage_backends[backend_type]
60
+
61
+ try:
62
+ # Create and cache instance
63
+ instance = backend_class()
64
+ _backend_instances[backend_type] = instance
65
+ logger.debug(f"Created instance for backend '{backend_type}'")
66
+ return instance
67
+ except Exception as e:
68
+ raise RuntimeError(f"Failed to instantiate backend '{backend_type}': {e}") from e
69
+
70
+
71
+ def create_storage_registry() -> Dict[str, DataSink]:
72
+ """
73
+ Create storage registry with all registered backends.
74
+
75
+ Returns:
76
+ Dictionary mapping backend types to instances
77
+ """
78
+ # Get backends registry (triggers import and registration)
79
+ storage_backends = _get_storage_backends()
80
+
81
+ # Backends that require context-specific initialization (e.g., plate_root)
82
+ # These are registered lazily when needed, not at startup
83
+ SKIP_BACKENDS = {'virtual_workspace'}
84
+
85
+ registry = {}
86
+ for backend_type in storage_backends.keys():
87
+ # Skip backends that need context-specific initialization
88
+ if backend_type in SKIP_BACKENDS:
89
+ logger.debug(f"Skipping backend '{backend_type}' - requires context-specific initialization")
90
+ continue
91
+
92
+ try:
93
+ registry[backend_type] = get_backend_instance(backend_type)
94
+ except Exception as e:
95
+ logger.warning(f"Failed to create instance for backend '{backend_type}': {e}")
96
+ continue
97
+
98
+ logger.info(f"Created storage registry with {len(registry)} backends: {list(registry.keys())}")
99
+ return registry
100
+
101
+
102
+ def cleanup_backend_connections() -> None:
103
+ """
104
+ Clean up backend connections without affecting persistent resources.
105
+
106
+ For napari streaming backend, this cleans up ZeroMQ connections but
107
+ leaves the napari window open for future use.
108
+ """
109
+ import os
110
+
111
+ # Check if we're running in test mode
112
+ is_test_mode = (
113
+ 'pytest' in os.environ.get('_', '') or
114
+ 'PYTEST_CURRENT_TEST' in os.environ or
115
+ any('pytest' in arg for arg in __import__('sys').argv)
116
+ )
117
+
118
+ for backend_type, instance in _backend_instances.items():
119
+ # Use targeted cleanup for napari streaming to preserve window
120
+ if hasattr(instance, 'cleanup_connections'):
121
+ try:
122
+ instance.cleanup_connections()
123
+ logger.debug(f"Cleaned up connections for backend '{backend_type}'")
124
+ except Exception as e:
125
+ logger.warning(f"Failed to cleanup connections for backend '{backend_type}': {e}")
126
+ elif hasattr(instance, 'cleanup') and backend_type != 'napari_stream':
127
+ try:
128
+ instance.cleanup()
129
+ logger.debug(f"Cleaned up backend '{backend_type}'")
130
+ except Exception as e:
131
+ logger.warning(f"Failed to cleanup backend '{backend_type}': {e}")
132
+
133
+ # In test mode, also stop viewer processes to allow pytest to exit
134
+ if is_test_mode:
135
+ try:
136
+ from openhcs.runtime.napari_stream_visualizer import _cleanup_global_viewer
137
+ _cleanup_global_viewer()
138
+ logger.debug("Cleaned up napari viewer for test mode")
139
+ except ImportError:
140
+ pass # napari not available
141
+ except Exception as e:
142
+ logger.warning(f"Failed to cleanup napari viewer: {e}")
143
+
144
+
145
+ class BackendRegistry(dict):
146
+ """
147
+ Registry for storage backends.
148
+
149
+ This is a dictionary that automatically populates with available backends
150
+ when first accessed.
151
+ """
152
+
153
+ def __init__(self):
154
+ """Initialize the backend registry."""
155
+ super().__init__()
156
+ # Populate with available backends
157
+ self.update(create_storage_registry())
158
+
159
+
160
+ def cleanup_all_backends() -> None:
161
+ """
162
+ Clean up all cached backend instances completely.
163
+
164
+ This is for full shutdown - clears instance cache and calls full cleanup.
165
+ Use cleanup_backend_connections() for test cleanup to preserve napari window.
166
+ """
167
+ for backend_type, instance in _backend_instances.items():
168
+ if hasattr(instance, 'cleanup'):
169
+ try:
170
+ instance.cleanup()
171
+ logger.debug(f"Cleaned up backend '{backend_type}'")
172
+ except Exception as e:
173
+ logger.warning(f"Failed to cleanup backend '{backend_type}': {e}")
174
+
175
+ _backend_instances.clear()
176
+ logger.info("All backend instances cleaned up")
177
+
178
+
179
+