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.
Files changed (53) hide show
  1. {ewoksdata-2.3.0rc4/src/ewoksdata.egg-info → ewoksdata-2.3.0rc5}/PKG-INFO +4 -2
  2. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/pyproject.toml +8 -2
  3. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/blissdata/__init__.py +18 -0
  4. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/blissdata/blissdatav2.py +8 -10
  5. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/mirror.py +20 -9
  6. ewoksdata-2.3.0rc5/src/ewoksdata/data/nfs.py +64 -0
  7. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/integration/conftest.py +22 -0
  8. ewoksdata-2.3.0rc5/src/ewoksdata/tests/integration/test_iter_memory.py +24 -0
  9. ewoksdata-2.3.0rc5/src/ewoksdata/tests/integration/test_iter_memory_live.py +170 -0
  10. 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
  11. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/test_dataset_writer.py +19 -15
  12. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/test_mirror_hdf5.py +16 -0
  13. ewoksdata-2.3.0rc5/src/ewoksdata/tests/unit/test_nfs.py +39 -0
  14. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/test_overwrite_hdf5.py +2 -1
  15. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5/src/ewoksdata.egg-info}/PKG-INFO +4 -2
  16. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata.egg-info/SOURCES.txt +5 -1
  17. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata.egg-info/requires.txt +6 -0
  18. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/LICENSE.md +0 -0
  19. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/README.md +0 -0
  20. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/setup.cfg +0 -0
  21. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/__init__.py +0 -0
  22. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/__init__.py +0 -0
  23. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/bliss.py +0 -0
  24. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/blissdata/_lima.py +0 -0
  25. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/blissdata/blissdatav0.py +0 -0
  26. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/blissdata/blissdatav1.py +0 -0
  27. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/contextiterator.py +0 -0
  28. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/__init__.py +0 -0
  29. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/config.py +0 -0
  30. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/dataset_writer/__init__.py +0 -0
  31. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/dataset_writer/_base.py +0 -0
  32. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/dataset_writer/_non_sequential.py +0 -0
  33. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/dataset_writer/_non_sequential_stack.py +0 -0
  34. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/dataset_writer/_sequential.py +0 -0
  35. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/dataset_writer/_sequential_stack.py +0 -0
  36. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/overwrite.py +0 -0
  37. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/hdf5/types.py +0 -0
  38. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/nexus.py +0 -0
  39. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/url.py +0 -0
  40. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/data/utils.py +0 -0
  41. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/__init__.py +0 -0
  42. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/integration/__init__.py +0 -0
  43. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/__init__.py +0 -0
  44. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/conftest.py +0 -0
  45. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/data/__init__.py +0 -0
  46. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/data/bliss_scans.py +0 -0
  47. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/test_contextiterator.py +0 -0
  48. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/test_data_bliss.py +0 -0
  49. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/test_data_hdf5.py +0 -0
  50. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/test_data_nexus.py +0 -0
  51. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata/tests/unit/test_find_lima_scans.py +0 -0
  52. {ewoksdata-2.3.0rc4 → ewoksdata-2.3.0rc5}/src/ewoksdata.egg-info/dependency_links.txt +0 -0
  53. {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.0rc4
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.0rc4"
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
- while True:
99
- try:
100
- ctr_values = [
101
- next(data_buffer) for data_buffer in data_buffers.values()
102
- ]
103
- except StopIteration:
104
- # One of the buffers ran out of data
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
- os.path.abspath(_get_hdf5_filename(original.file)) != home_original.file_path()
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 = os.path.relpath(absolute_path, home_original.data_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 os.path.abspath(_get_hdf5_filename(parent.file)) != home_original.file_path():
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 os.path.normpath(abs_parent_filename) == os.path.normpath(abs_target_filename):
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 = os.path.relpath(target_name, parent_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
- parent_filename = os.path.abspath(_get_hdf5_filename(parent.file))
700
- original_filename = os.path.abspath(_get_hdf5_filename(original.file))
701
- if os.path.normpath(parent_filename) != os.path.normpath(original_filename):
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
- except Exception:
216
- reader_process.terminate()
217
- raise
218
-
219
- # Do not rely on the exit code: with gevent monkey-patching (bliss
220
- # integration tests in the same session), once any subprocess was started,
221
- # gevent collects the exit code of every child process that terminates.
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) == 0o644
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.0rc4
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/test_blissdata.py
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
@@ -26,4 +26,10 @@ blissdata[lima2,tango]
26
26
 
27
27
  [test]
28
28
  ewoksdata[online]
29
+ pytest-timeout
30
+
31
+ [test:python_full_version != "3.10.0"]
29
32
  pytest>=7
33
+
34
+ [test:python_full_version == "3.10.0"]
35
+ pytest<9.2,>=7
File without changes
File without changes
File without changes