polystore 0.2.4__tar.gz → 0.2.6__tar.gz

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.
Files changed (96) hide show
  1. {polystore-0.2.4/src/polystore.egg-info → polystore-0.2.6}/PKG-INFO +15 -3
  2. {polystore-0.2.4 → polystore-0.2.6}/README.md +6 -2
  3. {polystore-0.2.4 → polystore-0.2.6}/pyproject.toml +14 -1
  4. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/__init__.py +30 -0
  5. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/atomic.py +56 -66
  6. polystore-0.2.6/src/polystore/imagej_distribution.py +508 -0
  7. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/imagej_runtime.py +40 -32
  8. polystore-0.2.6/src/polystore/omero_address.py +295 -0
  9. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/omero_local.py +98 -216
  10. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/zarr.py +2 -19
  11. {polystore-0.2.4 → polystore-0.2.6/src/polystore.egg-info}/PKG-INFO +15 -3
  12. {polystore-0.2.4 → polystore-0.2.6}/src/polystore.egg-info/SOURCES.txt +5 -0
  13. {polystore-0.2.4 → polystore-0.2.6}/src/polystore.egg-info/requires.txt +9 -0
  14. polystore-0.2.6/tests/test_atomic_lock.py +41 -0
  15. {polystore-0.2.4 → polystore-0.2.6}/tests/test_filemanager_backends.py +21 -46
  16. polystore-0.2.6/tests/test_imagej_distribution.py +269 -0
  17. {polystore-0.2.4 → polystore-0.2.6}/tests/test_imagej_runtime.py +114 -46
  18. polystore-0.2.6/tests/test_omero_address.py +134 -0
  19. {polystore-0.2.4 → polystore-0.2.6}/LICENSE +0 -0
  20. {polystore-0.2.4 → polystore-0.2.6}/setup.cfg +0 -0
  21. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/array_payload.py +0 -0
  22. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/async_init.py +0 -0
  23. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/backend_registry.py +0 -0
  24. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/base.py +0 -0
  25. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/bioformats_java.py +0 -0
  26. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/bioformats_storage.py +0 -0
  27. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/config.py +0 -0
  28. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/constants.py +0 -0
  29. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/disk.py +0 -0
  30. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/exceptions.py +0 -0
  31. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/fiji_stream.py +0 -0
  32. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/filemanager.py +0 -0
  33. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/formats.py +0 -0
  34. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/lazy_imports.py +0 -0
  35. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/memory.py +0 -0
  36. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/metadata_migration.py +0 -0
  37. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/metadata_writer.py +0 -0
  38. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/napari_stream.py +0 -0
  39. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/ome_zarr_storage.py +0 -0
  40. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/omero_tables.py +0 -0
  41. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/omero_text.py +0 -0
  42. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/roi.py +0 -0
  43. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/roi_converters.py +0 -0
  44. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/__init__.py +0 -0
  45. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/_streaming_backend.py +0 -0
  46. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/base.py +0 -0
  47. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/handlers/__init__.py +0 -0
  48. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/handlers/fiji_images.py +0 -0
  49. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/handlers/fiji_rois.py +0 -0
  50. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/identity.py +0 -0
  51. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/__init__.py +0 -0
  52. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/batching/__init__.py +0 -0
  53. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/core/__init__.py +0 -0
  54. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/core/batch_engine.py +0 -0
  55. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/core/contracts.py +0 -0
  56. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/core/window_projection.py +0 -0
  57. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/fiji/__init__.py +0 -0
  58. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/fiji/fiji_batch_processor.py +0 -0
  59. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/napari/__init__.py +0 -0
  60. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/napari/layer_key.py +0 -0
  61. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/napari/napari_batch_processor.py +0 -0
  62. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/viewer_transport.py +0 -0
  63. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming_constants.py +0 -0
  64. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/utils.py +0 -0
  65. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/virtual_workspace.py +0 -0
  66. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/zarr_batch.py +0 -0
  67. {polystore-0.2.4 → polystore-0.2.6}/src/polystore/zmq_config.py +0 -0
  68. {polystore-0.2.4 → polystore-0.2.6}/src/polystore.egg-info/dependency_links.txt +0 -0
  69. {polystore-0.2.4 → polystore-0.2.6}/src/polystore.egg-info/top_level.txt +0 -0
  70. {polystore-0.2.4 → polystore-0.2.6}/tests/test_array_payload.py +0 -0
  71. {polystore-0.2.4 → polystore-0.2.6}/tests/test_backend_registry.py +0 -0
  72. {polystore-0.2.4 → polystore-0.2.6}/tests/test_basic.py +0 -0
  73. {polystore-0.2.4 → polystore-0.2.6}/tests/test_bioformats_java.py +0 -0
  74. {polystore-0.2.4 → polystore-0.2.6}/tests/test_bioformats_storage.py +0 -0
  75. {polystore-0.2.4 → polystore-0.2.6}/tests/test_disk_backend.py +0 -0
  76. {polystore-0.2.4 → polystore-0.2.6}/tests/test_disk_coverage.py +0 -0
  77. {polystore-0.2.4 → polystore-0.2.6}/tests/test_disk_more.py +0 -0
  78. {polystore-0.2.4 → polystore-0.2.6}/tests/test_filemanager_extended.py +0 -0
  79. {polystore-0.2.4 → polystore-0.2.6}/tests/test_lazy_package_exports.py +0 -0
  80. {polystore-0.2.4 → polystore-0.2.6}/tests/test_memory_backend.py +0 -0
  81. {polystore-0.2.4 → polystore-0.2.6}/tests/test_memory_coverage.py +0 -0
  82. {polystore-0.2.4 → polystore-0.2.6}/tests/test_memory_type_authority.py +0 -0
  83. {polystore-0.2.4 → polystore-0.2.6}/tests/test_napari_batch_processor.py +0 -0
  84. {polystore-0.2.4 → polystore-0.2.6}/tests/test_ome_zarr_storage.py +0 -0
  85. {polystore-0.2.4 → polystore-0.2.6}/tests/test_omero_image_plane_batch.py +0 -0
  86. {polystore-0.2.4 → polystore-0.2.6}/tests/test_omero_tables.py +0 -0
  87. {polystore-0.2.4 → polystore-0.2.6}/tests/test_omero_text.py +0 -0
  88. {polystore-0.2.4 → polystore-0.2.6}/tests/test_png_format.py +0 -0
  89. {polystore-0.2.4 → polystore-0.2.6}/tests/test_roi.py +0 -0
  90. {polystore-0.2.4 → polystore-0.2.6}/tests/test_streaming_identity.py +0 -0
  91. {polystore-0.2.4 → polystore-0.2.6}/tests/test_streaming_metadata.py +0 -0
  92. {polystore-0.2.4 → polystore-0.2.6}/tests/test_streaming_receiver_core.py +0 -0
  93. {polystore-0.2.4 → polystore-0.2.6}/tests/test_streaming_shared_memory.py +0 -0
  94. {polystore-0.2.4 → polystore-0.2.6}/tests/test_viewer_transport.py +0 -0
  95. {polystore-0.2.4 → polystore-0.2.6}/tests/test_zarr_backend.py +0 -0
  96. {polystore-0.2.4 → polystore-0.2.6}/tests/test_zarr_batch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polystore
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Framework-agnostic multi-backend storage abstraction for ML and scientific computing
5
5
  Author-email: Tristan Simas <tristan.simas@mail.mcgill.ca>
6
6
  License-Expression: MIT
@@ -28,6 +28,7 @@ Requires-Dist: imageio>=2.37.0
28
28
  Requires-Dist: zarr<3.0,>=2.18.0
29
29
  Requires-Dist: ome-zarr>=0.11.0
30
30
  Requires-Dist: zmqruntime>=0.1.22
31
+ Requires-Dist: platformdirs>=4.0.0
31
32
  Provides-Extra: zarr
32
33
  Requires-Dist: zarr<3.0,>=2.18.0; extra == "zarr"
33
34
  Requires-Dist: ome-zarr>=0.11.0; extra == "zarr"
@@ -44,6 +45,10 @@ Requires-Dist: pyzmq>=25.0.0; extra == "streaming"
44
45
  Provides-Extra: omero
45
46
  Requires-Dist: pandas>=2.3.1; extra == "omero"
46
47
  Requires-Dist: omero-py>=5.19.0; extra == "omero"
48
+ Provides-Extra: bioformats
49
+ Requires-Dist: pyimagej==1.7.0; extra == "bioformats"
50
+ Requires-Dist: imglyb==2.1.0; extra == "bioformats"
51
+ Requires-Dist: scyjava==1.12.5; extra == "bioformats"
47
52
  Provides-Extra: all
48
53
  Requires-Dist: zarr<3.0,>=2.18.0; extra == "all"
49
54
  Requires-Dist: ome-zarr>=0.11.0; extra == "all"
@@ -52,6 +57,9 @@ Requires-Dist: jax>=0.4.0; extra == "all"
52
57
  Requires-Dist: tensorflow>=2.12.0; extra == "all"
53
58
  Requires-Dist: cupy>=12.0.0; extra == "all"
54
59
  Requires-Dist: pyzmq>=25.0.0; extra == "all"
60
+ Requires-Dist: pyimagej==1.7.0; extra == "all"
61
+ Requires-Dist: imglyb==2.1.0; extra == "all"
62
+ Requires-Dist: scyjava==1.12.5; extra == "all"
55
63
  Provides-Extra: dev
56
64
  Requires-Dist: pytest>=7.4.0; extra == "dev"
57
65
  Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
@@ -144,7 +152,11 @@ python -m pip install polystore
144
152
  ```
145
153
 
146
154
  The core install includes NumPy, ArrayBridge, metaclass-registry, file locking,
147
- image I/O, and Zarr/OME-Zarr support. Framework and streaming extras add their
148
- corresponding optional runtimes.
155
+ platform-aware cache paths, image I/O, Zarr/OME-Zarr support, and ZMQRuntime.
156
+ Framework and streaming extras add their corresponding optional runtimes.
157
+ Install ``polystore[bioformats]`` for the package-owned PyImageJ and
158
+ Bio-Formats bridge. Its first use materializes a checksummed Fiji, JDK, and
159
+ PyImageJ bridge runtime; later processes reuse that complete local cache
160
+ without Maven resolution.
149
161
 
150
162
  Full documentation: [polystore.readthedocs.io](https://polystore.readthedocs.io)
@@ -60,7 +60,11 @@ python -m pip install polystore
60
60
  ```
61
61
 
62
62
  The core install includes NumPy, ArrayBridge, metaclass-registry, file locking,
63
- image I/O, and Zarr/OME-Zarr support. Framework and streaming extras add their
64
- corresponding optional runtimes.
63
+ platform-aware cache paths, image I/O, Zarr/OME-Zarr support, and ZMQRuntime.
64
+ Framework and streaming extras add their corresponding optional runtimes.
65
+ Install ``polystore[bioformats]`` for the package-owned PyImageJ and
66
+ Bio-Formats bridge. Its first use materializes a checksummed Fiji, JDK, and
67
+ PyImageJ bridge runtime; later processes reuse that complete local cache
68
+ without Maven resolution.
65
69
 
66
70
  Full documentation: [polystore.readthedocs.io](https://polystore.readthedocs.io)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "polystore"
7
- version = "0.2.4"
7
+ version = "0.2.6"
8
8
  description = "Framework-agnostic multi-backend storage abstraction for ML and scientific computing"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -45,6 +45,7 @@ dependencies = [
45
45
  "zarr>=2.18.0,<3.0", # Required for ZarrStorageBackend
46
46
  "ome-zarr>=0.11.0", # Required for OME-ZARR HCS compliance
47
47
  "zmqruntime>=0.1.22",
48
+ "platformdirs>=4.0.0",
48
49
  ]
49
50
 
50
51
  [project.optional-dependencies]
@@ -78,6 +79,12 @@ omero = [
78
79
  "omero-py>=5.19.0",
79
80
  ]
80
81
 
82
+ bioformats = [
83
+ "pyimagej==1.7.0",
84
+ "imglyb==2.1.0",
85
+ "scyjava==1.12.5",
86
+ ]
87
+
81
88
  all = [
82
89
  "zarr>=2.18.0,<3.0",
83
90
  "ome-zarr>=0.11.0",
@@ -86,6 +93,9 @@ all = [
86
93
  "tensorflow>=2.12.0",
87
94
  "cupy>=12.0.0",
88
95
  "pyzmq>=25.0.0",
96
+ "pyimagej==1.7.0",
97
+ "imglyb==2.1.0",
98
+ "scyjava==1.12.5",
89
99
  ]
90
100
 
91
101
  dev = [
@@ -164,6 +174,9 @@ addopts = [
164
174
  "--cov-report=html",
165
175
  "--cov-report=xml",
166
176
  ]
177
+ markers = [
178
+ "live_imagej: resolves and initializes the declared managed ImageJ runtime",
179
+ ]
167
180
 
168
181
  [tool.coverage.run]
169
182
  source = ["polystore"]
@@ -38,6 +38,17 @@ from .base import (
38
38
  from .constants import Backend, MemoryType
39
39
  from .filemanager import FileManager
40
40
  from .formats import DEFAULT_IMAGE_EXTENSIONS, FileFormat
41
+ from .imagej_distribution import (
42
+ FIJI_IMAGEJ_DISTRIBUTION,
43
+ FijiArchiveDistribution,
44
+ FijiBundleAsset,
45
+ ImageJArchiveDownloadPolicy,
46
+ ImageJDistributionABC,
47
+ ImageJDistributionUnavailableError,
48
+ ImageJRuntimeArchive,
49
+ ImageJRuntimeLaunch,
50
+ ImageJRuntimeOverlay,
51
+ )
41
52
  from .imagej_runtime import (
42
53
  FIJI_IMAGEJ_RUNTIME,
43
54
  ImageJRuntimePolicy,
@@ -57,6 +68,12 @@ from .metadata_writer import (
57
68
  get_subdirectory_name,
58
69
  resolve_subdirectory_path,
59
70
  )
71
+ from .omero_address import (
72
+ OMEROPlaneAddress,
73
+ OMEROPlaneAxis,
74
+ OMEROPlaneCoordinates,
75
+ OMEROWellAddress,
76
+ )
60
77
  from .roi import (
61
78
  ROI,
62
79
  EllipseShape,
@@ -99,6 +116,15 @@ __all__ = [
99
116
  "ImageJRuntimePolicy",
100
117
  "ImageJRuntimeUnavailableError",
101
118
  "FIJI_IMAGEJ_RUNTIME",
119
+ "ImageJArchiveDownloadPolicy",
120
+ "ImageJDistributionABC",
121
+ "ImageJDistributionUnavailableError",
122
+ "ImageJRuntimeArchive",
123
+ "ImageJRuntimeLaunch",
124
+ "ImageJRuntimeOverlay",
125
+ "FijiArchiveDistribution",
126
+ "FijiBundleAsset",
127
+ "FIJI_IMAGEJ_DISTRIBUTION",
102
128
  "DEFAULT_IMAGE_EXTENSIONS",
103
129
  "BackendBase",
104
130
  "DataSink",
@@ -134,6 +160,10 @@ __all__ = [
134
160
  "get_metadata_path",
135
161
  "get_subdirectory_name",
136
162
  "resolve_subdirectory_path",
163
+ "OMEROPlaneAddress",
164
+ "OMEROPlaneAxis",
165
+ "OMEROPlaneCoordinates",
166
+ "OMEROWellAddress",
137
167
  "detect_legacy_format",
138
168
  "migrate_legacy_metadata",
139
169
  "migrate_plate_metadata",
@@ -10,32 +10,27 @@ import logging
10
10
  import os
11
11
  import tempfile
12
12
  import time
13
+ from collections.abc import Callable, Iterator
13
14
  from contextlib import contextmanager
14
15
  from dataclasses import dataclass
15
16
  from pathlib import Path
16
- from typing import Any, Callable, Dict, Optional, TypeVar, Union
17
+ from typing import Any, TypeVar
17
18
 
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
19
+ import portalocker
26
20
 
27
21
  logger = logging.getLogger(__name__)
28
22
 
29
- T = TypeVar('T')
23
+ T = TypeVar("T")
30
24
 
31
25
 
32
26
  @dataclass(frozen=True)
33
27
  class LockConfig:
34
28
  """Configuration constants for file locking operations."""
29
+
35
30
  DEFAULT_TIMEOUT: float = 30.0
36
31
  DEFAULT_POLL_INTERVAL: float = 0.1
37
- LOCK_SUFFIX: str = '.lock'
38
- TEMP_PREFIX: str = '.tmp'
32
+ LOCK_SUFFIX: str = ".lock"
33
+ TEMP_PREFIX: str = ".tmp"
39
34
  JSON_INDENT: int = 2
40
35
 
41
36
 
@@ -44,89 +39,82 @@ LOCK_CONFIG = LockConfig()
44
39
 
45
40
  class FileLockError(Exception):
46
41
  """Raised when file locking operations fail."""
47
- pass
48
42
 
49
43
 
50
44
  class FileLockTimeoutError(FileLockError):
51
45
  """Raised when file lock acquisition times out."""
52
- pass
53
46
 
54
47
 
55
48
  @contextmanager
56
49
  def file_lock(
57
- lock_path: Union[str, Path],
50
+ lock_path: str | Path,
58
51
  timeout: float = LOCK_CONFIG.DEFAULT_TIMEOUT,
59
- poll_interval: float = LOCK_CONFIG.DEFAULT_POLL_INTERVAL
60
- ):
52
+ poll_interval: float = LOCK_CONFIG.DEFAULT_POLL_INTERVAL,
53
+ ) -> Iterator[None]:
61
54
  """Context manager for exclusive file locking."""
62
55
  lock_path = Path(lock_path)
63
56
  lock_path.parent.mkdir(parents=True, exist_ok=True)
64
57
 
65
- lock_fd = None
58
+ lock_fd = _acquire_lock_with_timeout(lock_path, timeout, poll_interval)
66
59
  try:
67
- lock_fd = _acquire_lock_with_timeout(lock_path, timeout, poll_interval)
68
60
  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
61
  finally:
74
62
  _cleanup_lock(lock_fd, lock_path)
75
63
 
76
64
 
77
65
  def _acquire_lock_with_timeout(lock_path: Path, timeout: float, poll_interval: float) -> int:
78
66
  """Acquire file lock with timeout and return file descriptor."""
79
- deadline = time.time() + timeout
67
+ deadline = time.monotonic() + timeout
80
68
 
81
- while time.time() < deadline:
82
- if lock_fd := _try_acquire_lock(lock_path):
69
+ while True:
70
+ lock_fd = _try_acquire_lock(lock_path)
71
+ if lock_fd is not None:
83
72
  return lock_fd
84
- time.sleep(poll_interval)
73
+ remaining_seconds = deadline - time.monotonic()
74
+ if remaining_seconds <= 0:
75
+ break
76
+ time.sleep(min(poll_interval, remaining_seconds))
85
77
 
86
78
  raise FileLockTimeoutError(f"Failed to acquire lock {lock_path} within {timeout}s")
87
79
 
88
80
 
89
- def _try_acquire_lock(lock_path: Path) -> Optional[int]:
81
+ def _try_acquire_lock(lock_path: Path) -> int | None:
90
82
  """Try to acquire lock once, return fd or None."""
83
+ lock_fd = None
91
84
  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}")
85
+ lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_WRONLY, 0o600)
86
+ portalocker.lock(lock_fd, portalocker.LOCK_EX | portalocker.LOCK_NB)
87
+ logger.debug("Acquired file lock: %s", lock_path)
99
88
  return lock_fd
100
- except (OSError, IOError):
89
+ except (OSError, portalocker.exceptions.LockException):
90
+ if lock_fd is not None:
91
+ os.close(lock_fd)
101
92
  return None
93
+ except BaseException:
94
+ if lock_fd is not None:
95
+ os.close(lock_fd)
96
+ raise
102
97
 
103
98
 
104
- def _cleanup_lock(lock_fd: Optional[int], lock_path: Path) -> None:
99
+ def _cleanup_lock(lock_fd: int, lock_path: Path) -> None:
105
100
  """Clean up file lock resources."""
106
- if lock_fd is not None:
101
+ try:
102
+ portalocker.unlock(lock_fd)
103
+ logger.debug("Released file lock: %s", lock_path)
104
+ except (OSError, portalocker.exceptions.LockException) as exc:
105
+ logger.warning("Error releasing lock %s: %s", lock_path, exc)
106
+ finally:
107
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
108
  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}")
109
+ except OSError as exc:
110
+ logger.warning("Error closing lock %s: %s", lock_path, exc)
123
111
 
124
112
 
125
113
  def atomic_write_json(
126
- file_path: Union[str, Path],
127
- data: Dict[str, Any],
114
+ file_path: str | Path,
115
+ data: dict[str, Any],
128
116
  indent: int = LOCK_CONFIG.JSON_INDENT,
129
- ensure_directory: bool = True
117
+ ensure_directory: bool = True,
130
118
  ) -> None:
131
119
  """Atomically write JSON data to file using temporary file + rename."""
132
120
  file_path = Path(file_path)
@@ -144,14 +132,14 @@ def atomic_write_json(
144
132
  raise FileLockError(f"Atomic JSON write failed for {file_path}: {e}") from e
145
133
 
146
134
 
147
- def _write_to_temp_file(file_path: Path, data: Dict[str, Any], indent: int) -> str:
135
+ def _write_to_temp_file(file_path: Path, data: dict[str, Any], indent: int) -> str:
148
136
  """Write data to temporary file and return path."""
149
137
  with tempfile.NamedTemporaryFile(
150
- mode='w',
138
+ mode="w",
151
139
  dir=file_path.parent,
152
140
  prefix=f"{LOCK_CONFIG.TEMP_PREFIX}{file_path.name}",
153
- suffix='.json',
154
- delete=False
141
+ suffix=".json",
142
+ delete=False,
155
143
  ) as tmp_file:
156
144
  json.dump(data, tmp_file, indent=indent)
157
145
  tmp_file.flush()
@@ -160,14 +148,14 @@ def _write_to_temp_file(file_path: Path, data: Dict[str, Any], indent: int) -> s
160
148
 
161
149
 
162
150
  def atomic_update_json(
163
- file_path: Union[str, Path],
164
- update_func: Callable[[Optional[Dict[str, Any]]], Dict[str, Any]],
151
+ file_path: str | Path,
152
+ update_func: Callable[[dict[str, Any] | None], dict[str, Any]],
165
153
  lock_timeout: float = LOCK_CONFIG.DEFAULT_TIMEOUT,
166
- default_data: Optional[Dict[str, Any]] = None
154
+ default_data: dict[str, Any] | None = None,
167
155
  ) -> None:
168
156
  """Atomically update JSON file using read-modify-write with file locking."""
169
157
  file_path = Path(file_path)
170
- lock_path = file_path.with_suffix(f'{file_path.suffix}{LOCK_CONFIG.LOCK_SUFFIX}')
158
+ lock_path = file_path.with_suffix(f"{file_path.suffix}{LOCK_CONFIG.LOCK_SUFFIX}")
171
159
 
172
160
  with file_lock(lock_path, timeout=lock_timeout):
173
161
  current_data = _read_json_or_default(file_path, default_data)
@@ -181,14 +169,16 @@ def atomic_update_json(
181
169
  logger.debug(f"Atomically updated JSON file: {file_path}")
182
170
 
183
171
 
184
- def _read_json_or_default(file_path: Path, default_data: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
172
+ def _read_json_or_default(
173
+ file_path: Path, default_data: dict[str, Any] | None
174
+ ) -> dict[str, Any] | None:
185
175
  """Read JSON file or return default data if file doesn't exist or is invalid."""
186
176
  if not file_path.exists():
187
177
  return default_data
188
178
 
189
179
  try:
190
- with open(file_path, 'r') as f:
180
+ with open(file_path) as f:
191
181
  return json.load(f)
192
- except (json.JSONDecodeError, IOError) as e:
182
+ except (OSError, json.JSONDecodeError) as e:
193
183
  logger.warning(f"Failed to read {file_path}, using default: {e}")
194
184
  return default_data