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.
- {polystore-0.2.4/src/polystore.egg-info → polystore-0.2.6}/PKG-INFO +15 -3
- {polystore-0.2.4 → polystore-0.2.6}/README.md +6 -2
- {polystore-0.2.4 → polystore-0.2.6}/pyproject.toml +14 -1
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/__init__.py +30 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/atomic.py +56 -66
- polystore-0.2.6/src/polystore/imagej_distribution.py +508 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/imagej_runtime.py +40 -32
- polystore-0.2.6/src/polystore/omero_address.py +295 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/omero_local.py +98 -216
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/zarr.py +2 -19
- {polystore-0.2.4 → polystore-0.2.6/src/polystore.egg-info}/PKG-INFO +15 -3
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore.egg-info/SOURCES.txt +5 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore.egg-info/requires.txt +9 -0
- polystore-0.2.6/tests/test_atomic_lock.py +41 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_filemanager_backends.py +21 -46
- polystore-0.2.6/tests/test_imagej_distribution.py +269 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_imagej_runtime.py +114 -46
- polystore-0.2.6/tests/test_omero_address.py +134 -0
- {polystore-0.2.4 → polystore-0.2.6}/LICENSE +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/setup.cfg +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/array_payload.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/async_init.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/backend_registry.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/base.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/bioformats_java.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/bioformats_storage.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/config.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/constants.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/disk.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/exceptions.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/fiji_stream.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/filemanager.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/formats.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/lazy_imports.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/memory.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/metadata_migration.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/metadata_writer.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/napari_stream.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/ome_zarr_storage.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/omero_tables.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/omero_text.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/roi.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/roi_converters.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/__init__.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/_streaming_backend.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/base.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/handlers/__init__.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/handlers/fiji_images.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/handlers/fiji_rois.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/identity.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/__init__.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/batching/__init__.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/core/__init__.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/core/batch_engine.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/core/contracts.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/core/window_projection.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/fiji/__init__.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/fiji/fiji_batch_processor.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/napari/__init__.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/napari/layer_key.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/receivers/napari/napari_batch_processor.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming/viewer_transport.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/streaming_constants.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/utils.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/virtual_workspace.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/zarr_batch.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore/zmq_config.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore.egg-info/dependency_links.txt +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/src/polystore.egg-info/top_level.txt +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_array_payload.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_backend_registry.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_basic.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_bioformats_java.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_bioformats_storage.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_disk_backend.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_disk_coverage.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_disk_more.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_filemanager_extended.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_lazy_package_exports.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_memory_backend.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_memory_coverage.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_memory_type_authority.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_napari_batch_processor.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_ome_zarr_storage.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_omero_image_plane_batch.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_omero_tables.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_omero_text.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_png_format.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_roi.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_streaming_identity.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_streaming_metadata.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_streaming_receiver_core.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_streaming_shared_memory.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_viewer_transport.py +0 -0
- {polystore-0.2.4 → polystore-0.2.6}/tests/test_zarr_backend.py +0 -0
- {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.
|
|
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,
|
|
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,
|
|
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.
|
|
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,
|
|
17
|
+
from typing import Any, TypeVar
|
|
17
18
|
|
|
18
|
-
|
|
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(
|
|
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 =
|
|
38
|
-
TEMP_PREFIX: str =
|
|
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:
|
|
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 =
|
|
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.
|
|
67
|
+
deadline = time.monotonic() + timeout
|
|
80
68
|
|
|
81
|
-
while
|
|
82
|
-
|
|
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.
|
|
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) ->
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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,
|
|
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:
|
|
99
|
+
def _cleanup_lock(lock_fd: int, lock_path: Path) -> None:
|
|
105
100
|
"""Clean up file lock resources."""
|
|
106
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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:
|
|
127
|
-
data:
|
|
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:
|
|
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=
|
|
138
|
+
mode="w",
|
|
151
139
|
dir=file_path.parent,
|
|
152
140
|
prefix=f"{LOCK_CONFIG.TEMP_PREFIX}{file_path.name}",
|
|
153
|
-
suffix=
|
|
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:
|
|
164
|
-
update_func: Callable[[
|
|
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:
|
|
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
|
|
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(
|
|
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
|
|
180
|
+
with open(file_path) as f:
|
|
191
181
|
return json.load(f)
|
|
192
|
-
except (json.JSONDecodeError
|
|
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
|