ewoksdata 2.3.0rc4__tar.gz → 2.3.0rc5__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.
- {ewoksdata-2.3.0rc4/src/ewoksdata.egg-info → ewoksdata-2.3.0rc5}/PKG-INFO +4 -2
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/pyproject.toml +8 -2
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/blissdata/__init__.py +18 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/blissdata/blissdatav2.py +8 -10
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/mirror.py +20 -9
- ewoksdata-2.3.0rc5/src/ewoksdata/data/nfs.py +64 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/integration/conftest.py +22 -0
- ewoksdata-2.3.0rc5/src/ewoksdata/tests/integration/test_iter_memory.py +24 -0
- ewoksdata-2.3.0rc5/src/ewoksdata/tests/integration/test_iter_memory_live.py +170 -0
- ewoksdata-2.3.0rc4/src/ewoksdata/tests/integration/test_blissdata.py → ewoksdata-2.3.0rc5/src/ewoksdata/tests/integration/test_last_lima_image.py +0 -24
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/test_dataset_writer.py +19 -15
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/test_mirror_hdf5.py +16 -0
- ewoksdata-2.3.0rc5/src/ewoksdata/tests/unit/test_nfs.py +39 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/test_overwrite_hdf5.py +2 -1
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5/src/ewoksdata.egg-info}/PKG-INFO +4 -2
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata.egg-info/SOURCES.txt +5 -1
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata.egg-info/requires.txt +6 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/LICENSE.md +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/README.md +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/setup.cfg +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/__init__.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/__init__.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/bliss.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/blissdata/_lima.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/blissdata/blissdatav0.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/blissdata/blissdatav1.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/contextiterator.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/__init__.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/config.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/dataset_writer/__init__.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/dataset_writer/_base.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/dataset_writer/_non_sequential.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/dataset_writer/_non_sequential_stack.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/dataset_writer/_sequential.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/dataset_writer/_sequential_stack.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/overwrite.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/types.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/nexus.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/url.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/utils.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/__init__.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/integration/__init__.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/__init__.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/conftest.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/data/__init__.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/data/bliss_scans.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/test_contextiterator.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/test_data_bliss.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/test_data_hdf5.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/test_data_nexus.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/test_find_lima_scans.py +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata.egg-info/dependency_links.txt +0 -0
- {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ewoksdata
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.0rc5
|
|
4
4
|
Summary: Ewoks tasks for data access
|
|
5
5
|
Author-email: ESRF <dau-pydev@esrf.fr>
|
|
6
6
|
License: # MIT License
|
|
@@ -49,7 +49,9 @@ Provides-Extra: online
|
|
|
49
49
|
Requires-Dist: blissdata[lima2,tango]; extra == "online"
|
|
50
50
|
Provides-Extra: test
|
|
51
51
|
Requires-Dist: ewoksdata[online]; extra == "test"
|
|
52
|
-
Requires-Dist: pytest>=7; extra == "test"
|
|
52
|
+
Requires-Dist: pytest>=7; python_full_version != "3.10.0" and extra == "test"
|
|
53
|
+
Requires-Dist: pytest<9.2,>=7; python_full_version == "3.10.0" and extra == "test"
|
|
54
|
+
Requires-Dist: pytest-timeout; extra == "test"
|
|
53
55
|
Provides-Extra: dev
|
|
54
56
|
Requires-Dist: ewoksdata[test]; extra == "dev"
|
|
55
57
|
Requires-Dist: ruff; extra == "dev"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ewoksdata"
|
|
7
|
-
version = "2.3.
|
|
7
|
+
version = "2.3.0rc5"
|
|
8
8
|
authors = [{ name = "ESRF", email = "dau-pydev@esrf.fr" }]
|
|
9
9
|
description = "Ewoks tasks for data access"
|
|
10
10
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
@@ -41,7 +41,9 @@ online = [
|
|
|
41
41
|
]
|
|
42
42
|
test = [
|
|
43
43
|
"ewoksdata[online]",
|
|
44
|
-
"pytest >=7",
|
|
44
|
+
"pytest >=7; python_full_version != '3.10.0'",
|
|
45
|
+
"pytest >=7,<9.2; python_full_version == '3.10.0'", # pytest 9.2 needs bpo-44649 fixed
|
|
46
|
+
"pytest-timeout",
|
|
45
47
|
]
|
|
46
48
|
dev = [
|
|
47
49
|
"ewoksdata[test]",
|
|
@@ -63,6 +65,10 @@ where = ["src"]
|
|
|
63
65
|
[tool.coverage.run]
|
|
64
66
|
omit = ['*/tests/*']
|
|
65
67
|
|
|
68
|
+
[tool.pytest.ini_options]
|
|
69
|
+
timeout = 120
|
|
70
|
+
timeout_method = "thread"
|
|
71
|
+
|
|
66
72
|
[tool.ruff]
|
|
67
73
|
line-length = 88
|
|
68
74
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
from importlib.metadata import version
|
|
2
3
|
|
|
3
4
|
from packaging.version import Version
|
|
@@ -7,9 +8,26 @@ def _not_supported_by_blissdata_version(*_, **kw):
|
|
|
7
8
|
raise RuntimeError(f"Not supported by blissdata {_BLISSDATA_VERSION}")
|
|
8
9
|
|
|
9
10
|
|
|
11
|
+
def _warn_redis_socket_timeout() -> None:
|
|
12
|
+
"""Log a warning when the installed redis and blissdata versions make blocking redis calls time out."""
|
|
13
|
+
redis_version = Version(version("redis"))
|
|
14
|
+
blissdata_affected = Version("1") <= _BLISSDATA_VERSION < Version("2.4.6")
|
|
15
|
+
if blissdata_affected and redis_version >= Version("8"):
|
|
16
|
+
_logger.warning(
|
|
17
|
+
"redis %s with blissdata %s: blocking redis calls time out after 5 seconds. "
|
|
18
|
+
"Use redis<8 or blissdata>=2.4.6.",
|
|
19
|
+
redis_version,
|
|
20
|
+
_BLISSDATA_VERSION,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
10
27
|
from blissdata.h5api import dynamic_hdf5 # noqa F401
|
|
11
28
|
|
|
12
29
|
_BLISSDATA_VERSION = Version(version("blissdata"))
|
|
30
|
+
_warn_redis_socket_timeout()
|
|
13
31
|
|
|
14
32
|
if _BLISSDATA_VERSION >= Version("2.0.0rc1"):
|
|
15
33
|
from .blissdatav2 import iter_bliss_scan_data_from_memory # noqa F401
|
|
@@ -95,16 +95,13 @@ def iter_bliss_scan_data_from_memory(
|
|
|
95
95
|
for data_buffer in data_buffers.values():
|
|
96
96
|
data_buffer.buffer_in_memory()
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
break
|
|
106
|
-
|
|
107
|
-
yield dict(zip(ctr_names, ctr_values))
|
|
98
|
+
# Only consume points available for all counters to keep them aligned
|
|
99
|
+
nyield = min(data_buffer.length_left for data_buffer in data_buffers.values())
|
|
100
|
+
for _ in range(nyield):
|
|
101
|
+
yield {
|
|
102
|
+
ctr_name: next(data_buffer)
|
|
103
|
+
for ctr_name, data_buffer in data_buffers.items()
|
|
104
|
+
}
|
|
108
105
|
|
|
109
106
|
|
|
110
107
|
class _DataBuffer(Iterator):
|
|
@@ -152,6 +149,7 @@ class _DataBuffer(Iterator):
|
|
|
152
149
|
def __next__(self):
|
|
153
150
|
"""Return next scan data point for this counter."""
|
|
154
151
|
if self._buffer:
|
|
152
|
+
self._total_yielded += 1
|
|
155
153
|
return self._buffer.pop(0)
|
|
156
154
|
|
|
157
155
|
if len(self._views) == 0:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import os
|
|
3
|
+
import posixpath
|
|
3
4
|
from typing import Any
|
|
4
5
|
from typing import NamedTuple
|
|
5
6
|
from typing import Optional
|
|
@@ -234,8 +235,8 @@ def _mirror(
|
|
|
234
235
|
# Whether opening `original` would mean reading it from a file other
|
|
235
236
|
# than the one being mirrored: never allowed unless `allow_cross_file`,
|
|
236
237
|
# regardless of what `expand`/`recursive` ask for (see `traverse` below).
|
|
237
|
-
crosses_file = (
|
|
238
|
-
|
|
238
|
+
crosses_file = not _same_file(
|
|
239
|
+
_get_hdf5_filename(original.file), home_original.file_path()
|
|
239
240
|
)
|
|
240
241
|
|
|
241
242
|
# Open `original` as a group instead of mirroring it as a single link or
|
|
@@ -554,7 +555,7 @@ def _relative_to_home_original(absolute_path: str, home_original: DataUrl) -> st
|
|
|
554
555
|
"""HDF5 path of :code:`absolute_path` relative to :code:`home_original`'s path,
|
|
555
556
|
empty for :code:`home_original` itself.
|
|
556
557
|
"""
|
|
557
|
-
relative_path =
|
|
558
|
+
relative_path = posixpath.relpath(absolute_path, home_original.data_path())
|
|
558
559
|
if relative_path == ".":
|
|
559
560
|
return ""
|
|
560
561
|
return relative_path
|
|
@@ -573,7 +574,7 @@ def _soft_link_in_scope(
|
|
|
573
574
|
"""Does the soft link target :code:`target_path` refer to a path within
|
|
574
575
|
:code:`home_original`'s path?
|
|
575
576
|
"""
|
|
576
|
-
if
|
|
577
|
+
if not _same_file(_get_hdf5_filename(parent.file), home_original.file_path()):
|
|
577
578
|
return False
|
|
578
579
|
return _in_scope(target_path, home_original.data_path())
|
|
579
580
|
|
|
@@ -668,7 +669,7 @@ def _hdf5_link_object(
|
|
|
668
669
|
)
|
|
669
670
|
|
|
670
671
|
# Internal link
|
|
671
|
-
if
|
|
672
|
+
if _same_file(abs_parent_filename, abs_target_filename):
|
|
672
673
|
link_full_name = _normalize_hdf5_item_name(parent_name, link_name)
|
|
673
674
|
target_name = _normalize_hdf5_item_name(target_name)
|
|
674
675
|
if link_full_name == target_name:
|
|
@@ -676,7 +677,7 @@ def _hdf5_link_object(
|
|
|
676
677
|
return None
|
|
677
678
|
if not relative:
|
|
678
679
|
return h5py.SoftLink(target_name)
|
|
679
|
-
rel_target_name =
|
|
680
|
+
rel_target_name = posixpath.relpath(target_name, parent_name)
|
|
680
681
|
if ".." in rel_target_name:
|
|
681
682
|
# Internal links upwards are not supported
|
|
682
683
|
return h5py.SoftLink(target_name)
|
|
@@ -696,9 +697,9 @@ def _mirrors_itself(
|
|
|
696
697
|
parent: GroupType, name: str, original: Union[DatasetType, GroupType]
|
|
697
698
|
) -> bool:
|
|
698
699
|
"""Is :code:`name` in :code:`parent` the HDF5 item :code:`original` itself?"""
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
700
|
+
if not _same_file(
|
|
701
|
+
_get_hdf5_filename(parent.file), _get_hdf5_filename(original.file)
|
|
702
|
+
):
|
|
702
703
|
return False
|
|
703
704
|
return _normalize_hdf5_item_name(parent.name, name) == _normalize_hdf5_item_name(
|
|
704
705
|
original.name
|
|
@@ -812,3 +813,13 @@ def _get_hdf5_filename(file_obj: FileType) -> str:
|
|
|
812
813
|
except AttributeError:
|
|
813
814
|
pass
|
|
814
815
|
raise AttributeError(f"cannot determine the HDF5 filename of {file_obj!r}")
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _same_file(filename1: str, filename2: str) -> bool:
|
|
819
|
+
"""Do both filenames refer to the same file?"""
|
|
820
|
+
return _normalize_filename(filename1) == _normalize_filename(filename2)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _normalize_filename(filename: str) -> str:
|
|
824
|
+
# On Windows the same file can be spelled with `\` or `/` and in any case
|
|
825
|
+
return os.path.normcase(os.path.normpath(os.path.abspath(filename)))
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import List
|
|
3
|
+
from typing import Set
|
|
4
|
+
from typing import Union
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def nfs_cache_refresh(*paths: Union[str, os.PathLike]) -> None:
|
|
8
|
+
"""Refresh the NFS client cache of the paths so their latest state is visible.
|
|
9
|
+
|
|
10
|
+
Paths that do not exist (yet) refresh their closest existing ancestor directory.
|
|
11
|
+
"""
|
|
12
|
+
refreshed: Set[str] = set()
|
|
13
|
+
for path in paths:
|
|
14
|
+
_refresh_path(os.path.abspath(path), refreshed)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _refresh_path(path: str, refreshed: Set[str]) -> None:
|
|
18
|
+
missing: List[str] = []
|
|
19
|
+
dirname = path
|
|
20
|
+
while not os.path.isdir(dirname):
|
|
21
|
+
missing.append(dirname)
|
|
22
|
+
parent = os.path.dirname(dirname)
|
|
23
|
+
if parent == dirname:
|
|
24
|
+
return
|
|
25
|
+
dirname = parent
|
|
26
|
+
|
|
27
|
+
# Listing a directory can reveal children that were cached as non-existing
|
|
28
|
+
while True:
|
|
29
|
+
_refresh_directory(dirname, refreshed)
|
|
30
|
+
if not missing:
|
|
31
|
+
return
|
|
32
|
+
child = missing.pop()
|
|
33
|
+
if os.path.isdir(child):
|
|
34
|
+
dirname = child
|
|
35
|
+
else:
|
|
36
|
+
if os.path.exists(child):
|
|
37
|
+
_refresh_file(child)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _refresh_directory(dirname: str, refreshed: Set[str]) -> None:
|
|
42
|
+
if dirname in refreshed:
|
|
43
|
+
return
|
|
44
|
+
refreshed.add(dirname)
|
|
45
|
+
try:
|
|
46
|
+
with os.scandir(dirname) as entries:
|
|
47
|
+
for entry in entries:
|
|
48
|
+
try:
|
|
49
|
+
_ = (
|
|
50
|
+
entry.stat()
|
|
51
|
+
) # This forces a fresh stat call, similar to "ls -l"
|
|
52
|
+
except OSError:
|
|
53
|
+
pass
|
|
54
|
+
except OSError:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _refresh_file(filename: str) -> None:
|
|
59
|
+
# Close-to-open consistency: opening revalidates the cached attributes
|
|
60
|
+
try:
|
|
61
|
+
fd = os.open(filename, os.O_RDONLY)
|
|
62
|
+
except OSError:
|
|
63
|
+
return
|
|
64
|
+
os.close(fd)
|
|
@@ -42,6 +42,28 @@ def requires_blissdata_v1() -> None:
|
|
|
42
42
|
pytest.skip("requires blissdata>=1")
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
@pytest.fixture(scope="session")
|
|
46
|
+
def requires_blissdata_v2() -> None:
|
|
47
|
+
if Version(version("blissdata")) < Version("2.0.0rc1"):
|
|
48
|
+
pytest.skip("requires blissdata>=2.0.0rc1")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.fixture(scope="session")
|
|
52
|
+
def data_store(requires_blissdata_v2):
|
|
53
|
+
"""Blissdata data store, skip when Redis is not available."""
|
|
54
|
+
if not os.environ.get("BEACON_HOST"):
|
|
55
|
+
pytest.skip("BEACON_HOST is not set")
|
|
56
|
+
|
|
57
|
+
# Deferred: these modules do not exist in older blissdata versions
|
|
58
|
+
from blissdata.beacon.data import BeaconData
|
|
59
|
+
from blissdata.redis_engine.store import DataStore
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
return DataStore(BeaconData().get_redis_data_db())
|
|
63
|
+
except Exception as ex:
|
|
64
|
+
pytest.skip(f"Cannot connect to Redis: {ex}")
|
|
65
|
+
|
|
66
|
+
|
|
45
67
|
@pytest.fixture(scope="session")
|
|
46
68
|
def demo_session():
|
|
47
69
|
"""Activate the Bliss demo session, skip when it is not available."""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from ...data.blissdata import iter_bliss_scan_data_from_memory
|
|
2
|
+
from ...data.blissdata import iter_bliss_scan_data_from_memory_slice
|
|
3
|
+
|
|
4
|
+
LIMA_NAMES = ["difflab6"]
|
|
5
|
+
COUNTER_NAMES = ["diode1"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_iter_memory(scan_key):
|
|
9
|
+
n = 0
|
|
10
|
+
for data in iter_bliss_scan_data_from_memory(scan_key, LIMA_NAMES, COUNTER_NAMES):
|
|
11
|
+
assert set(data) == {"difflab6", "diode1"}
|
|
12
|
+
assert data["difflab6"].ndim == 2
|
|
13
|
+
n += 1
|
|
14
|
+
assert n == 10
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_iter_slice_memory(scan_key, requires_blissdata_v1):
|
|
18
|
+
n = 0
|
|
19
|
+
for data in iter_bliss_scan_data_from_memory_slice(
|
|
20
|
+
scan_key, LIMA_NAMES, COUNTER_NAMES, slice_range=(3, 5)
|
|
21
|
+
):
|
|
22
|
+
assert set(data) == {"difflab6", "diode1"}
|
|
23
|
+
n += 1
|
|
24
|
+
assert n == 2
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
from typing import Any
|
|
5
|
+
from typing import Dict
|
|
6
|
+
from typing import Iterator
|
|
7
|
+
from typing import List
|
|
8
|
+
|
|
9
|
+
import numpy
|
|
10
|
+
import pytest
|
|
11
|
+
from packaging.version import Version
|
|
12
|
+
|
|
13
|
+
if Version(version("blissdata")) < Version("2.0.0rc1"):
|
|
14
|
+
pytest.skip("requires blissdata >= 2.0.0rc1", allow_module_level=True)
|
|
15
|
+
|
|
16
|
+
from blissdata.redis_engine.scan import Scan
|
|
17
|
+
|
|
18
|
+
if Version(version("blissdata")) >= Version("2.3.0"):
|
|
19
|
+
from blissdata.streams import BaseStream
|
|
20
|
+
from blissdata.streams.default import Stream
|
|
21
|
+
else:
|
|
22
|
+
from blissdata.streams.base import BaseStream
|
|
23
|
+
from blissdata.streams.base import Stream
|
|
24
|
+
|
|
25
|
+
from ...data.blissdata import blissdatav2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.mark.parametrize(
|
|
29
|
+
"slow_stream,fast_stream", [("first", "second"), ("second", "first")]
|
|
30
|
+
)
|
|
31
|
+
def test_iter_memory_streams_out_of_sync(data_store, caplog, slow_stream, fast_stream):
|
|
32
|
+
"""One stream receives its data slower than the other one while the scan
|
|
33
|
+
data is being read: data points must not be lost nor misaligned."""
|
|
34
|
+
npoints = 12
|
|
35
|
+
stream_data = {
|
|
36
|
+
"first": numpy.arange(npoints),
|
|
37
|
+
"second": numpy.arange(1000, 1000 + npoints),
|
|
38
|
+
}
|
|
39
|
+
counter_names = list(stream_data)
|
|
40
|
+
timeout = 30 # seconds
|
|
41
|
+
|
|
42
|
+
# Each feeding round sends 3 points to the fast stream, then 2 to the slow one
|
|
43
|
+
feed_order = [fast_stream, slow_stream]
|
|
44
|
+
chunk_sizes = {fast_stream: 3, slow_stream: 2}
|
|
45
|
+
|
|
46
|
+
# Points in both streams after each feeding round are those of the slow stream
|
|
47
|
+
slow_chunk = chunk_sizes[slow_stream]
|
|
48
|
+
npoints_per_round = list(range(slow_chunk, npoints + 1, slow_chunk))
|
|
49
|
+
|
|
50
|
+
scan_identity = {
|
|
51
|
+
"name": "streams_out_of_sync",
|
|
52
|
+
"number": 1,
|
|
53
|
+
"data_policy": "no_policy",
|
|
54
|
+
}
|
|
55
|
+
scan = data_store.create_scan(scan_identity)
|
|
56
|
+
reader = _ScanReader(scan.key, counter_names=counter_names, timeout=timeout)
|
|
57
|
+
with caplog.at_level(logging.INFO, logger=blissdatav2.logger.name):
|
|
58
|
+
try:
|
|
59
|
+
streams = _create_streams(scan, stream_data)
|
|
60
|
+
scan.prepare()
|
|
61
|
+
scan.start()
|
|
62
|
+
reader.start()
|
|
63
|
+
for npoints_sent in _feed_streams(
|
|
64
|
+
streams,
|
|
65
|
+
stream_data,
|
|
66
|
+
feed_order=feed_order,
|
|
67
|
+
chunk_sizes=chunk_sizes,
|
|
68
|
+
npoints_per_round=npoints_per_round,
|
|
69
|
+
):
|
|
70
|
+
reader.wait_for_points(npoints_sent)
|
|
71
|
+
scan.stop() # Seals the streams: the reader gets the end of the streams
|
|
72
|
+
reader.join(timeout=timeout)
|
|
73
|
+
finally:
|
|
74
|
+
# Seals the streams if not done yet so the reader never blocks forever
|
|
75
|
+
scan.close()
|
|
76
|
+
|
|
77
|
+
# The reader reached the end of the streams without errors
|
|
78
|
+
assert not reader.is_alive(), f"Reader not done after {timeout} seconds"
|
|
79
|
+
if reader.errors:
|
|
80
|
+
raise reader.errors[0]
|
|
81
|
+
|
|
82
|
+
# Every point was read once, with the values of both streams aligned
|
|
83
|
+
expected_data = [
|
|
84
|
+
{name: data[i] for name, data in stream_data.items()} for i in range(npoints)
|
|
85
|
+
]
|
|
86
|
+
assert reader.data == expected_data
|
|
87
|
+
|
|
88
|
+
# The iterator logged that it yielded every point of each stream, with no warnings
|
|
89
|
+
expected_progress = "\n".join(
|
|
90
|
+
f"{name}: {npoints}/{npoints}" for name in counter_names
|
|
91
|
+
)
|
|
92
|
+
assert expected_progress in caplog.text
|
|
93
|
+
assert not [r for r in caplog.records if r.levelname == "WARNING"]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _ScanReader(threading.Thread):
|
|
97
|
+
"""Reads scan data from memory in a background thread."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, scan_key: str, counter_names: List[str], timeout: float) -> None:
|
|
100
|
+
super().__init__(daemon=True)
|
|
101
|
+
self._scan_key = scan_key
|
|
102
|
+
self._counter_names = counter_names
|
|
103
|
+
self._timeout = timeout
|
|
104
|
+
self._data_added = threading.Condition()
|
|
105
|
+
self._finished = False
|
|
106
|
+
self.data: List[Dict[str, Any]] = []
|
|
107
|
+
self.errors: List[BaseException] = []
|
|
108
|
+
|
|
109
|
+
def run(self) -> None:
|
|
110
|
+
try:
|
|
111
|
+
for point in blissdatav2.iter_bliss_scan_data_from_memory(
|
|
112
|
+
self._scan_key, counter_names=self._counter_names
|
|
113
|
+
):
|
|
114
|
+
with self._data_added:
|
|
115
|
+
self.data.append(point)
|
|
116
|
+
self._data_added.notify_all()
|
|
117
|
+
except BaseException as ex:
|
|
118
|
+
self.errors.append(ex)
|
|
119
|
+
finally:
|
|
120
|
+
with self._data_added:
|
|
121
|
+
self._finished = True
|
|
122
|
+
self._data_added.notify_all()
|
|
123
|
+
|
|
124
|
+
def wait_for_points(self, npoints: int) -> None:
|
|
125
|
+
"""Wait until `npoints` points were read, or reading stopped."""
|
|
126
|
+
|
|
127
|
+
def points_read() -> bool:
|
|
128
|
+
return len(self.data) >= npoints or self._finished
|
|
129
|
+
|
|
130
|
+
with self._data_added:
|
|
131
|
+
done = self._data_added.wait_for(points_read, timeout=self._timeout)
|
|
132
|
+
assert done, f"Read {len(self.data)} of {npoints} points"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _create_streams(
|
|
136
|
+
scan: Scan, stream_data: Dict[str, numpy.ndarray]
|
|
137
|
+
) -> Dict[str, BaseStream]:
|
|
138
|
+
"""Create one stream per counter. The iterator finds a counter by the last
|
|
139
|
+
part of the stream name."""
|
|
140
|
+
return {
|
|
141
|
+
name: scan.create_stream(Stream.make_definition(f"sim:{name}", data.dtype.type))
|
|
142
|
+
for name, data in stream_data.items()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _feed_streams(
|
|
147
|
+
streams: Dict[str, BaseStream],
|
|
148
|
+
stream_data: Dict[str, numpy.ndarray],
|
|
149
|
+
feed_order: List[str],
|
|
150
|
+
chunk_sizes: Dict[str, int],
|
|
151
|
+
npoints_per_round: List[int],
|
|
152
|
+
) -> Iterator[int]:
|
|
153
|
+
"""Each round sends the next chunk to each stream in `feed_order`, then yields
|
|
154
|
+
the number of points the reader can read after that round."""
|
|
155
|
+
positions = {name: 0 for name in feed_order}
|
|
156
|
+
for npoints in npoints_per_round:
|
|
157
|
+
for name in feed_order:
|
|
158
|
+
start = positions[name]
|
|
159
|
+
stop = start + chunk_sizes[name]
|
|
160
|
+
chunk = stream_data[name][start:stop]
|
|
161
|
+
if len(chunk):
|
|
162
|
+
streams[name].send(chunk)
|
|
163
|
+
streams[name].join()
|
|
164
|
+
positions[name] = stop
|
|
165
|
+
yield npoints
|
|
166
|
+
|
|
167
|
+
not_sent = [name for name in feed_order if positions[name] < len(stream_data[name])]
|
|
168
|
+
assert not not_sent, (
|
|
169
|
+
f"Data not sent after {len(npoints_per_round)} rounds: {not_sent}"
|
|
170
|
+
)
|
|
@@ -2,32 +2,8 @@ from importlib.metadata import version
|
|
|
2
2
|
|
|
3
3
|
from packaging.version import Version
|
|
4
4
|
|
|
5
|
-
from ...data.blissdata import iter_bliss_scan_data_from_memory
|
|
6
|
-
from ...data.blissdata import iter_bliss_scan_data_from_memory_slice
|
|
7
5
|
from ...data.blissdata import last_lima_image
|
|
8
6
|
|
|
9
|
-
LIMA_NAMES = ["difflab6"]
|
|
10
|
-
COUNTER_NAMES = ["diode1"]
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def test_iter_memory(scan_key):
|
|
14
|
-
n = 0
|
|
15
|
-
for data in iter_bliss_scan_data_from_memory(scan_key, LIMA_NAMES, COUNTER_NAMES):
|
|
16
|
-
assert set(data) == {"difflab6", "diode1"}
|
|
17
|
-
assert data["difflab6"].ndim == 2
|
|
18
|
-
n += 1
|
|
19
|
-
assert n == 10
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_iter_slice_memory(scan_key, requires_blissdata_v1):
|
|
23
|
-
n = 0
|
|
24
|
-
for data in iter_bliss_scan_data_from_memory_slice(
|
|
25
|
-
scan_key, LIMA_NAMES, COUNTER_NAMES, slice_range=(3, 5)
|
|
26
|
-
):
|
|
27
|
-
assert set(data) == {"difflab6", "diode1"}
|
|
28
|
-
n += 1
|
|
29
|
-
assert n == 2
|
|
30
|
-
|
|
31
7
|
|
|
32
8
|
def test_last_lima_image(scan_key):
|
|
33
9
|
blissdata_version = Version(version("blissdata"))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import itertools
|
|
2
2
|
import multiprocessing
|
|
3
|
+
import sys
|
|
3
4
|
import time
|
|
4
5
|
|
|
5
6
|
import h5py
|
|
@@ -182,6 +183,12 @@ def test_indexed_stack_dataset_writer(
|
|
|
182
183
|
numpy.testing.assert_allclose(data, expected)
|
|
183
184
|
|
|
184
185
|
|
|
186
|
+
# HDF5 locks with LockFileEx on Windows, which blocks reads from other processes:
|
|
187
|
+
# https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-lockfileex
|
|
188
|
+
@pytest.mark.skipif(
|
|
189
|
+
sys.platform == "win32",
|
|
190
|
+
reason="non-locking HDF5 readers cannot read a file locked by a writer",
|
|
191
|
+
)
|
|
185
192
|
def test_concurrent_reader(tmp_path):
|
|
186
193
|
npoints = 50
|
|
187
194
|
hdf5_filename = str(tmp_path / "test.h5")
|
|
@@ -206,27 +213,24 @@ def test_concurrent_reader(tmp_path):
|
|
|
206
213
|
),
|
|
207
214
|
)
|
|
208
215
|
reader_process.start()
|
|
209
|
-
|
|
210
|
-
assert start_event.wait(timeout=10)
|
|
211
|
-
|
|
212
|
-
# Run write loop
|
|
213
216
|
try:
|
|
217
|
+
assert start_event.wait(timeout=30), "Reader did not start within the timeout."
|
|
218
|
+
|
|
214
219
|
_write_hdf5_file(hdf5_filename, dataset_name, npoints, write_timestamps, nread)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
# The exit code of the reader is then no longer available to
|
|
223
|
-
# multiprocessing, so `reader_process.exitcode` stays `None`.
|
|
224
|
-
try:
|
|
225
|
-
assert done_event.wait(timeout=20), "Reader did not finish within the timeout."
|
|
220
|
+
|
|
221
|
+
# Do not rely on the exit code: with gevent monkey-patching (bliss
|
|
222
|
+
# integration tests in the same session), once any subprocess was started,
|
|
223
|
+
# gevent collects the exit code of every child process that terminates.
|
|
224
|
+
# The exit code of the reader is then no longer available to
|
|
225
|
+
# multiprocessing, so `reader_process.exitcode` stays `None`.
|
|
226
|
+
assert done_event.wait(timeout=30), "Reader did not finish within the timeout."
|
|
226
227
|
finally:
|
|
228
|
+
# Always reap the reader: a leaked process on the CI host keeps
|
|
229
|
+
# running and can disrupt unrelated jobs sharing the same machine.
|
|
227
230
|
reader_process.join(timeout=5)
|
|
228
231
|
if reader_process.is_alive():
|
|
229
232
|
reader_process.terminate()
|
|
233
|
+
reader_process.join(timeout=5)
|
|
230
234
|
|
|
231
235
|
read_events = _load_events(read_timestamps)
|
|
232
236
|
write_events = _load_events(write_timestamps)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import contextlib
|
|
2
2
|
import gc
|
|
3
3
|
import logging
|
|
4
|
+
import sys
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Generator
|
|
6
7
|
from typing import Type
|
|
@@ -11,6 +12,7 @@ import numpy
|
|
|
11
12
|
import pytest
|
|
12
13
|
|
|
13
14
|
from ...data.blissdata import dynamic_hdf5
|
|
15
|
+
from ...data.hdf5.mirror import _same_file
|
|
14
16
|
from ...data.hdf5.mirror import mirror_hdf5_content
|
|
15
17
|
|
|
16
18
|
|
|
@@ -1206,3 +1208,17 @@ def _get_hdf5_structure(item: Union[h5py.Group, h5py.Dataset]) -> dict:
|
|
|
1206
1208
|
content[name] = subcontent
|
|
1207
1209
|
|
|
1208
1210
|
return content
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
@pytest.mark.parametrize("spelling", ["relative_parts", "separators", "case"])
|
|
1214
|
+
def test_same_file(tmp_path, spelling):
|
|
1215
|
+
filename = str(tmp_path / "data" / "raw.h5")
|
|
1216
|
+
if spelling == "relative_parts":
|
|
1217
|
+
other = str(tmp_path / "data" / ".." / "data" / "raw.h5")
|
|
1218
|
+
elif sys.platform != "win32":
|
|
1219
|
+
pytest.skip("Windows only")
|
|
1220
|
+
elif spelling == "separators":
|
|
1221
|
+
other = filename.replace("\\", "/")
|
|
1222
|
+
else:
|
|
1223
|
+
other = filename.upper()
|
|
1224
|
+
assert _same_file(filename, other)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from ...data.nfs import nfs_cache_refresh
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_nfs_cache_refresh(tmp_path):
|
|
10
|
+
filename = tmp_path / "subdir" / "file.txt"
|
|
11
|
+
filename.parent.mkdir()
|
|
12
|
+
filename.write_text("content")
|
|
13
|
+
|
|
14
|
+
nfs_cache_refresh(
|
|
15
|
+
filename,
|
|
16
|
+
str(filename),
|
|
17
|
+
filename.parent,
|
|
18
|
+
tmp_path / "missing" / "file.txt",
|
|
19
|
+
tmp_path / "subdir" / "missing.txt",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.mark.skipif(
|
|
24
|
+
sys.platform == "win32" or os.geteuid() == 0,
|
|
25
|
+
reason="permissions are not enforced",
|
|
26
|
+
)
|
|
27
|
+
def test_nfs_cache_refresh_no_permission(tmp_path):
|
|
28
|
+
filename = tmp_path / "file.txt"
|
|
29
|
+
filename.write_text("content")
|
|
30
|
+
dirname = tmp_path / "subdir"
|
|
31
|
+
dirname.mkdir()
|
|
32
|
+
|
|
33
|
+
filename.chmod(0)
|
|
34
|
+
dirname.chmod(0)
|
|
35
|
+
try:
|
|
36
|
+
nfs_cache_refresh(filename, dirname, dirname / "file.txt")
|
|
37
|
+
finally:
|
|
38
|
+
dirname.chmod(0o755)
|
|
39
|
+
filename.chmod(0o644)
|
|
@@ -35,6 +35,7 @@ def test_ensure_items_do_not_exist_some_top_level(tmp_path):
|
|
|
35
35
|
f["3.1/soft"] = h5py.SoftLink("/2.1/data")
|
|
36
36
|
f["3.1/external"] = h5py.ExternalLink("other.h5", "/data")
|
|
37
37
|
os.chmod(filename, 0o644)
|
|
38
|
+
mode_before = stat.S_IMODE(os.stat(filename).st_mode)
|
|
38
39
|
size_before = os.path.getsize(filename)
|
|
39
40
|
|
|
40
41
|
ensure_items_do_not_exist(f"{filename}::/1.1", delete_existing=True)
|
|
@@ -47,7 +48,7 @@ def test_ensure_items_do_not_exist_some_top_level(tmp_path):
|
|
|
47
48
|
assert f.get("3.1/soft", getlink=True).path == "/2.1/data"
|
|
48
49
|
assert f.get("3.1/external", getlink=True).filename == "other.h5"
|
|
49
50
|
assert os.path.getsize(filename) < size_before / 10
|
|
50
|
-
assert stat.S_IMODE(os.stat(filename).st_mode) ==
|
|
51
|
+
assert stat.S_IMODE(os.stat(filename).st_mode) == mode_before
|
|
51
52
|
assert os.listdir(tmp_path) == ["test.h5"]
|
|
52
53
|
|
|
53
54
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ewoksdata
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.0rc5
|
|
4
4
|
Summary: Ewoks tasks for data access
|
|
5
5
|
Author-email: ESRF <dau-pydev@esrf.fr>
|
|
6
6
|
License: # MIT License
|
|
@@ -49,7 +49,9 @@ Provides-Extra: online
|
|
|
49
49
|
Requires-Dist: blissdata[lima2,tango]; extra == "online"
|
|
50
50
|
Provides-Extra: test
|
|
51
51
|
Requires-Dist: ewoksdata[online]; extra == "test"
|
|
52
|
-
Requires-Dist: pytest>=7; extra == "test"
|
|
52
|
+
Requires-Dist: pytest>=7; python_full_version != "3.10.0" and extra == "test"
|
|
53
|
+
Requires-Dist: pytest<9.2,>=7; python_full_version == "3.10.0" and extra == "test"
|
|
54
|
+
Requires-Dist: pytest-timeout; extra == "test"
|
|
53
55
|
Provides-Extra: dev
|
|
54
56
|
Requires-Dist: ewoksdata[test]; extra == "dev"
|
|
55
57
|
Requires-Dist: ruff; extra == "dev"
|
|
@@ -11,6 +11,7 @@ src/ewoksdata/data/__init__.py
|
|
|
11
11
|
src/ewoksdata/data/bliss.py
|
|
12
12
|
src/ewoksdata/data/contextiterator.py
|
|
13
13
|
src/ewoksdata/data/nexus.py
|
|
14
|
+
src/ewoksdata/data/nfs.py
|
|
14
15
|
src/ewoksdata/data/url.py
|
|
15
16
|
src/ewoksdata/data/utils.py
|
|
16
17
|
src/ewoksdata/data/blissdata/__init__.py
|
|
@@ -32,7 +33,9 @@ src/ewoksdata/data/hdf5/dataset_writer/_sequential_stack.py
|
|
|
32
33
|
src/ewoksdata/tests/__init__.py
|
|
33
34
|
src/ewoksdata/tests/integration/__init__.py
|
|
34
35
|
src/ewoksdata/tests/integration/conftest.py
|
|
35
|
-
src/ewoksdata/tests/integration/
|
|
36
|
+
src/ewoksdata/tests/integration/test_iter_memory.py
|
|
37
|
+
src/ewoksdata/tests/integration/test_iter_memory_live.py
|
|
38
|
+
src/ewoksdata/tests/integration/test_last_lima_image.py
|
|
36
39
|
src/ewoksdata/tests/unit/__init__.py
|
|
37
40
|
src/ewoksdata/tests/unit/conftest.py
|
|
38
41
|
src/ewoksdata/tests/unit/test_contextiterator.py
|
|
@@ -42,6 +45,7 @@ src/ewoksdata/tests/unit/test_data_nexus.py
|
|
|
42
45
|
src/ewoksdata/tests/unit/test_dataset_writer.py
|
|
43
46
|
src/ewoksdata/tests/unit/test_find_lima_scans.py
|
|
44
47
|
src/ewoksdata/tests/unit/test_mirror_hdf5.py
|
|
48
|
+
src/ewoksdata/tests/unit/test_nfs.py
|
|
45
49
|
src/ewoksdata/tests/unit/test_overwrite_hdf5.py
|
|
46
50
|
src/ewoksdata/tests/unit/data/__init__.py
|
|
47
51
|
src/ewoksdata/tests/unit/data/bliss_scans.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/dataset_writer/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/dataset_writer/_non_sequential.py
RENAMED
|
File without changes
|
|
File without changes
|
{ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/dataset_writer/_sequential.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|