blissdata 2.3.0__tar.gz → 2.4.1__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 (101) hide show
  1. {blissdata-2.3.0/src/blissdata.egg-info → blissdata-2.4.1}/PKG-INFO +4 -4
  2. {blissdata-2.3.0 → blissdata-2.4.1}/pyproject.toml +4 -4
  3. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/h5api/utils/hdf5.py +12 -1
  4. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/h5map/__init__.py +1 -0
  5. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/h5map/data_items.py +74 -9
  6. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/lima/client.py +46 -26
  7. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/lima/image_utils.py +2 -2
  8. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/redis_engine/memory_tracker.py +11 -4
  9. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/redis_engine/store.py +2 -2
  10. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/schemas/scan_info_display.py +0 -1
  11. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/streams/lima/stream.py +39 -47
  12. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/beacon/test_files.py +2 -2
  13. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/conftest.py +0 -1
  14. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/h5api/test_dynamic_files.py +89 -0
  15. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/h5api/test_redis_files.py +6 -0
  16. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/h5map/test_data_items.py +91 -1
  17. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/lima/test_image_utils.py +2 -2
  18. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/redis_engine/test_memory_tracker.py +11 -5
  19. blissdata-2.4.1/src/blissdata/tests/streams/plugins/test_lima_stream.py +168 -0
  20. {blissdata-2.3.0 → blissdata-2.4.1/src/blissdata.egg-info}/PKG-INFO +4 -4
  21. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata.egg-info/SOURCES.txt +1 -0
  22. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata.egg-info/requires.txt +3 -3
  23. {blissdata-2.3.0 → blissdata-2.4.1}/LICENSE +0 -0
  24. {blissdata-2.3.0 → blissdata-2.4.1}/README.md +0 -0
  25. {blissdata-2.3.0 → blissdata-2.4.1}/setup.cfg +0 -0
  26. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/__init__.py +0 -0
  27. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/beacon/__init__.py +0 -0
  28. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/beacon/_base.py +0 -0
  29. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/beacon/_utils.py +0 -0
  30. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/beacon/config.py +0 -0
  31. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/beacon/data.py +0 -0
  32. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/beacon/files.py +0 -0
  33. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/deprecation.py +0 -0
  34. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/exceptions.py +0 -0
  35. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/h5api/__init__.py +0 -0
  36. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/h5api/abstract.py +0 -0
  37. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/h5api/dynamic_hdf5.py +0 -0
  38. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/h5api/file_arguments.py +0 -0
  39. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/h5api/h5_scan_map.py +0 -0
  40. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/h5api/redis_hdf5.py +0 -0
  41. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/h5api/static_hdf5.py +0 -0
  42. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/h5api/utils/__init__.py +0 -0
  43. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/h5api/utils/bliss.py +0 -0
  44. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/h5api/utils/hdf5_retry.py +0 -0
  45. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/h5api/utils/lima.py +0 -0
  46. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/h5api/utils/types.py +0 -0
  47. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/h5map/hdf5_items.py +0 -0
  48. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/lima/__init__.py +0 -0
  49. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/py.typed +0 -0
  50. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/redis_engine/__init__.py +0 -0
  51. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/redis_engine/encoding/__init__.py +0 -0
  52. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/redis_engine/encoding/json.py +0 -0
  53. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/redis_engine/encoding/numeric.py +0 -0
  54. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/redis_engine/event_stream.py +0 -0
  55. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/redis_engine/exceptions.py +0 -0
  56. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/redis_engine/identities.py +0 -0
  57. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/redis_engine/scan.py +0 -0
  58. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/scan.py +0 -0
  59. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/schemas/__init__.py +0 -0
  60. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/schemas/scan_info.py +0 -0
  61. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/streams/__init__.py +0 -0
  62. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/streams/base.py +0 -0
  63. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/streams/default.py +0 -0
  64. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/streams/encoding/__init__.py +0 -0
  65. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/streams/encoding/json.py +0 -0
  66. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/streams/encoding/numeric.py +0 -0
  67. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/streams/event_stream.py +0 -0
  68. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/streams/hdf5_fallback/__init__.py +0 -0
  69. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/streams/hdf5_fallback/stream.py +0 -0
  70. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/streams/lima/__init__.py +0 -0
  71. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/streams/scan_sequence/__init__.py +0 -0
  72. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/streams/scan_sequence/stream.py +0 -0
  73. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/streams/sink.py +0 -0
  74. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/__init__.py +0 -0
  75. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/beacon/__init__.py +0 -0
  76. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/beacon/test_config.py +0 -0
  77. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/beacon/test_data.py +0 -0
  78. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/h5api/__init__.py +0 -0
  79. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/h5api/scanner.py +0 -0
  80. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/h5api/test_h5api_lima_utils.py +0 -0
  81. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/h5api/test_hdf5_handlers.py +0 -0
  82. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/h5api/test_static_files.py +0 -0
  83. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/h5map/__init__.py +0 -0
  84. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/h5map/test_hdf5_items.py +0 -0
  85. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/lima/__init__.py +0 -0
  86. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/redis_engine/__init__.py +0 -0
  87. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/redis_engine/conftest.py +0 -0
  88. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/redis_engine/test_cursor.py +0 -0
  89. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/redis_engine/test_scan.py +0 -0
  90. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/redis_engine/test_search.py +0 -0
  91. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/redis_engine/utils.py +0 -0
  92. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/streams/encoding/__init__.py +0 -0
  93. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/streams/encoding/test_json.py +0 -0
  94. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/streams/encoding/test_numeric.py +0 -0
  95. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/streams/plugins/__init__.py +0 -0
  96. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/streams/plugins/test_file_backed_stream.py +0 -0
  97. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/streams/plugins/test_plugin_base.py +0 -0
  98. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata/tests/streams/test_event_stream.py +0 -0
  99. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata.egg-info/dependency_links.txt +0 -0
  100. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata.egg-info/entry_points.txt +0 -0
  101. {blissdata-2.3.0 → blissdata-2.4.1}/src/blissdata.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blissdata
3
- Version: 2.3.0
3
+ Version: 2.4.1
4
4
  Summary: Bliss data streaming client
5
5
  Maintainer: BCU (ESRF)
6
6
  License-Expression: LGPL-3.0-or-later
@@ -19,9 +19,9 @@ Requires-Dist: h5py>=2.9
19
19
  Requires-Dist: msgpack
20
20
  Requires-Dist: numpy>=1.20
21
21
  Requires-Dist: numpydantic<1.6.10
22
- Requires-Dist: pydantic>=2.3.0
22
+ Requires-Dist: pydantic>=2.9
23
23
  Requires-Dist: redis>=4.6.0
24
- Requires-Dist: redis-om<1.0,>=0.3.1
24
+ Requires-Dist: redis-om~=1.0
25
25
  Requires-Dist: silx>=1.1.0
26
26
  Provides-Extra: tango
27
27
  Requires-Dist: pytango>=9.3.1; extra == "tango"
@@ -36,7 +36,7 @@ Requires-Dist: pytest-timeout; extra == "test"
36
36
  Requires-Dist: packaging; extra == "test"
37
37
  Provides-Extra: dev
38
38
  Requires-Dist: blissdata[test]; extra == "dev"
39
- Requires-Dist: black==22.3; extra == "dev"
39
+ Requires-Dist: black; extra == "dev"
40
40
  Requires-Dist: flake8>=4; extra == "dev"
41
41
  Requires-Dist: flake8-pep585; extra == "dev"
42
42
  Requires-Dist: mkdocs; extra == "dev"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "blissdata"
3
- version = "2.3.0"
3
+ version = "2.4.1"
4
4
  description = "Bliss data streaming client"
5
5
  readme = "README.md"
6
6
  license = "LGPL-3.0-or-later"
@@ -19,9 +19,9 @@ dependencies = [
19
19
  "msgpack",
20
20
  "numpy>=1.20",
21
21
  "numpydantic<1.6.10",
22
- "pydantic>=2.3.0", # (not a direct dep) redis-om 0.3.2 doesn't support lower versions
22
+ "pydantic>=2.9",
23
23
  "redis>=4.6.0",
24
- "redis-om>=0.3.1,<1.0",
24
+ "redis-om~=1.0",
25
25
  "silx>=1.1.0",
26
26
  ]
27
27
 
@@ -43,7 +43,7 @@ test = [
43
43
  ]
44
44
  dev = [
45
45
  "blissdata[test]",
46
- "black==22.3",
46
+ "black",
47
47
  "flake8>=4",
48
48
  "flake8-pep585",
49
49
  "mkdocs",
@@ -251,7 +251,7 @@ class DynamicHDF5Handler:
251
251
  try:
252
252
  return item.attrs[key] # try one more time
253
253
  except KeyError as e:
254
- if not _key_does_not_exist(e):
254
+ if not _attribute_does_not_exist(e):
255
255
  raise
256
256
  # attribute will never be created
257
257
  raise RetryTimeoutError(str(e)) from e
@@ -366,3 +366,14 @@ def _key_does_not_exist(exc: KeyError) -> bool:
366
366
 
367
367
  """
368
368
  return "doesn't exist" in str(exc)
369
+
370
+
371
+ def _attribute_does_not_exist(exc: KeyError) -> bool:
372
+ """
373
+ A real KeyError (meaning that the attribute does not exist) gives the following error message:
374
+
375
+ .. code
376
+
377
+ KeyError: "Unable to synchronously open attribute (can't locate attribute: 'myattr')"
378
+ """
379
+ return "can't locate attribute" in str(exc)
@@ -4,6 +4,7 @@ from .data_items import ( # noqa: F401
4
4
  InlineRaw,
5
5
  Inline,
6
6
  InlineBase64,
7
+ Compound,
7
8
  Stream,
8
9
  )
9
10
  from .hdf5_items import ( # noqa: F401
@@ -6,6 +6,7 @@ from pydantic import (
6
6
  Base64Bytes,
7
7
  Field,
8
8
  field_validator,
9
+ model_validator,
9
10
  model_serializer,
10
11
  Discriminator,
11
12
  Tag,
@@ -64,7 +65,13 @@ class InlineRaw(RootModel):
64
65
  def decode(self):
65
66
  value = self.model_dump()
66
67
  if value.dtype.kind in "UT":
67
- return np.vectorize(lambda x: x.encode(), otypes="O")(value)
68
+ # Make strings similar to those read back from h5py:
69
+ # - Convert to "object" dtype
70
+ # - Tell h5py that "object" is variable length string using
71
+ # metadata, so writing doesn't fail even in a compound dataset.
72
+ return np.vectorize(
73
+ lambda x: x.encode(), otypes=[np.dtype("O", metadata={"vlen": str})]
74
+ )(value)
68
75
  else:
69
76
  return value
70
77
 
@@ -155,14 +162,6 @@ class InlineBase64(BaseModel):
155
162
  return value
156
163
 
157
164
 
158
- class Stream(BaseModel):
159
- type: Literal["stream"] = "stream"
160
- stream: str
161
-
162
- def decode(self):
163
- raise TypeError("Stream model is just a reference, can't use decode()")
164
-
165
-
166
165
  def get_discriminator_value(v: Any) -> str:
167
166
  if isinstance(v, dict):
168
167
  return v.get("type")
@@ -171,12 +170,78 @@ def get_discriminator_value(v: Any) -> str:
171
170
  return "raw"
172
171
 
173
172
 
173
+ class Compound(BaseModel):
174
+ """Composite model to combine multiple DataItems as a structured array.
175
+ Unlike numpy structured arrays where data is a list of heterogeneous tuples,
176
+ here all elements of one dtype are stored together. This way existing
177
+ DataItem models can be reused as columns.
178
+
179
+ Validation ensures:
180
+ - Column names uniqueness
181
+ - Shape consistency between columns
182
+ """
183
+
184
+ type: Literal["compound"] = "compound"
185
+ columns: list[
186
+ tuple[
187
+ str,
188
+ Annotated[
189
+ (
190
+ Annotated[Inline, Tag("inline")]
191
+ | Annotated[InlineBase64, Tag("inline_b64")]
192
+ | Annotated[InlineRaw, Tag("raw")]
193
+ | Annotated["Compound", Tag("compound")]
194
+ # Note: Stream can't be part of a compound object
195
+ ),
196
+ Discriminator(get_discriminator_value),
197
+ ],
198
+ ]
199
+ ] = Field(min_length=1)
200
+ # Reusing DataItemType simplifies this model as it does not redefine
201
+ # handling of dtype, shape or base64 version of the values.
202
+ # Therefore the representation is the transposed of numpy one, which can be
203
+ # harder to read.
204
+
205
+ def decode(self):
206
+ decoded_columns = [(name, value.decode()) for name, value in self.columns]
207
+ rec = np.rec.fromarrays(
208
+ [value for _, value in decoded_columns],
209
+ names=[name for name, _ in decoded_columns],
210
+ )
211
+ return rec.view(np.ndarray)
212
+
213
+ @model_validator(mode="after")
214
+ @classmethod
215
+ def verify_names_and_shapes(cls, value):
216
+ """Ensure uniqueness of names and shape equality between columns"""
217
+ if len(value.columns) != len({name for name, _ in value.columns}):
218
+ raise ValueError("found columns with same name")
219
+
220
+ shape_iterator = iter((col.decode().shape for _, col in value.columns))
221
+ try:
222
+ first_shape = next(shape_iterator)
223
+ except StopIteration:
224
+ return value
225
+ if not all(first_shape == shape for shape in shape_iterator):
226
+ raise ValueError("columns have different shapes")
227
+ return value
228
+
229
+
230
+ class Stream(BaseModel):
231
+ type: Literal["stream"] = "stream"
232
+ stream: str
233
+
234
+ def decode(self):
235
+ raise TypeError("Stream model is just a reference, can't use decode()")
236
+
237
+
174
238
  DataItemType = Annotated[
175
239
  (
176
240
  Annotated[Inline, Tag("inline")]
177
241
  | Annotated[InlineBase64, Tag("inline_b64")]
178
242
  | Annotated[Stream, Tag("stream")]
179
243
  | Annotated[InlineRaw, Tag("raw")]
244
+ | Annotated[Compound, Tag("compound")]
180
245
  ),
181
246
  Discriminator(get_discriminator_value),
182
247
  ]
@@ -5,13 +5,13 @@
5
5
  # Copyright (c) Beamline Control Unit, ESRF
6
6
  # Distributed under the GNU LGPLv3. See LICENSE for more info.
7
7
  import math
8
+ import time
8
9
  import logging
9
10
  import numpy as np
10
11
  from packaging.version import Version
11
12
  from functools import cached_property
12
13
  from dataclasses import dataclass
13
14
 
14
- from blissdata.exceptions import IndexNoMoreThereError, IndexNotYetThereError
15
15
  from . import image_utils
16
16
 
17
17
  try:
@@ -58,7 +58,7 @@ class ImageReference:
58
58
  class LimaClient:
59
59
  PROTOCOL_VERSION = 1
60
60
 
61
- def __init__(self, data_store, **lima_info):
61
+ def __init__(self, data_store, request_update_cb, **lima_info):
62
62
  protocol = lima_info["protocol_version"]
63
63
  if self.PROTOCOL_VERSION != protocol:
64
64
  raise Exception(
@@ -66,6 +66,8 @@ class LimaClient:
66
66
  )
67
67
 
68
68
  self._data_store = data_store
69
+ self._request_update = request_update_cb
70
+ self._stream_sealed = False
69
71
  self._proxy = None
70
72
  self._server_url = lima_info["server_url"]
71
73
  self._buffer_max_number = lima_info["buffer_max_number"]
@@ -123,9 +125,10 @@ class LimaClient:
123
125
  def __len__(self):
124
126
  return self._last_index + 1
125
127
 
126
- def update(self, last_index, last_index_saved):
128
+ def update(self, last_index=-1, last_index_saved=-1, sealed=False):
127
129
  self._last_index = max(self._last_index, last_index)
128
130
  self._last_index_saved = max(self._last_index_saved, last_index_saved)
131
+ self._stream_sealed = self._stream_sealed or sealed
129
132
  if self._saved:
130
133
  self._last_index_saved_and_closed = self._last_readable_index()
131
134
 
@@ -155,10 +158,14 @@ class LimaClient:
155
158
 
156
159
  def _last_readable_index(self):
157
160
  """Return the last frame index that can be read from a closed file, assuming a
158
- file is closed when at least one frame is saved into the next one.
161
+ file is closed either when stream is sealed or when at least one frame is saved
162
+ into the next file.
159
163
  NOTE: This is trickier than a modulo as an acquisition may not be an exact
160
164
  multiple of the file length (see self._decompose_frame_id).
161
165
  """
166
+ if self._stream_sealed:
167
+ return self._last_index_saved
168
+
162
169
  acq_in_scan, file_in_acq, _ = self._decompose_frame_id(self._last_index_saved)
163
170
  return (
164
171
  acq_in_scan * self._frames_per_acquisition
@@ -215,7 +222,7 @@ class LimaClient:
215
222
  frame_id = normalize_frame_id(data.frame_id)
216
223
  return image_utils.ImageData(data.array, frame_id, None)
217
224
 
218
- def __getitem__(self, key):
225
+ def __getitem__(self, key: int | slice):
219
226
  if isinstance(key, int):
220
227
  return self._get_index(key)
221
228
  elif isinstance(key, slice):
@@ -232,21 +239,28 @@ class LimaClient:
232
239
  raise NotImplementedError("Negative index not supported")
233
240
 
234
241
  if self._saved and frame_id <= self._last_index_saved_and_closed:
235
- # immediately accessible from file
242
+ # 1. priority to file if available
236
243
  return self._get_from_file(frame_id)
237
244
  elif frame_id <= self._last_index:
238
- # try to access on lima server
239
245
  try:
246
+ # 2. try from server then
240
247
  return self._get_from_server(frame_id)
241
- except IndexNoMoreThereError:
242
- # wait for image to become available from file
243
- # TODO wait_for_file(timeout)
248
+ except IndexError:
244
249
  if self._saved:
250
+ # 3. finally wait for file
251
+ while (
252
+ frame_id > self._last_index_saved_and_closed
253
+ and not self._stream_sealed
254
+ ):
255
+ self._request_update()
256
+ time.sleep(0.1)
257
+ if frame_id > self._last_index_saved_and_closed:
258
+ raise
245
259
  return self._get_from_file(frame_id)
246
260
  else:
247
261
  raise
248
262
  else:
249
- raise IndexNotYetThereError
263
+ raise IndexError
250
264
 
251
265
  def _get_slice(self, key):
252
266
  key_range = range(*key.indices(len(self)))
@@ -267,7 +281,10 @@ class LimaClient:
267
281
 
268
282
  def _get_from_server(self, frame_id):
269
283
  if self._buffer_max_number <= (self._last_index - frame_id):
270
- raise IndexNoMoreThereError() # TODO IndexError here and more details in LimaStream ???
284
+ # if frame_id is in the current acquisition, then it's not in the
285
+ # buffer any longer. If another acquisition started, it's not here
286
+ # neither.
287
+ raise IndexError
271
288
 
272
289
  acq_in_scan, file_in_acq, frame_in_file = self._decompose_frame_id(frame_id)
273
290
  frame_in_acq = file_in_acq * self._frames_per_file + frame_in_file
@@ -278,15 +295,24 @@ class LimaClient:
278
295
  if not self._is_acquisition_still_on_server(
279
296
  acq_in_scan + self._acquisition_offset
280
297
  ):
281
- raise IndexNoMoreThereError() # TODO IndexError here and more details in LimaStream ???
298
+ raise IndexError
282
299
 
283
- frame = image_utils.image_from_server(self.proxy, frame_in_acq)
284
- assert frame_in_acq == frame.frame_id
300
+ try:
301
+ frame = image_utils.image_from_server(self.proxy, frame_in_acq)
302
+ except (RuntimeError, image_utils.NoImageAvailable) as e:
303
+ raise IndexError from e
304
+
305
+ if frame_in_acq != frame.frame_id:
306
+ _logger.warning(
307
+ f"Requested frame {frame_in_acq} from lima device, "
308
+ f"got frame {frame.frame_id}. Discarding it."
309
+ )
310
+ raise IndexError
285
311
 
286
312
  if not self._is_acquisition_still_on_server(
287
313
  acq_in_scan + self._acquisition_offset
288
314
  ):
289
- raise IndexNoMoreThereError() # TODO IndexError here and more details in LimaStream ???
315
+ raise IndexError
290
316
 
291
317
  return frame.array
292
318
 
@@ -296,19 +322,13 @@ class LimaClient:
296
322
  ref.file_path, ref.data_path, ref.index, ref.format
297
323
  )
298
324
 
299
- def get_references(self, key) -> ImageReference | list[ImageReference]:
300
- # NOTE: _last_index is used to define when references are available.
301
- # It should normally be _last_index_saved instead, but then, a LimaView
302
- # would not return the same amount of images and references.
303
- saved_len = self._last_index + 1
325
+ def get_references(self, key: int | slice) -> ImageReference | list[ImageReference]:
326
+ """Create references no matter the images are available or not. It is
327
+ caller responsibility to query indices that make sense."""
304
328
  if isinstance(key, int):
305
- if key < 0:
306
- key += saved_len
307
- elif key >= saved_len:
308
- raise IndexNotYetThereError
309
329
  return self._file_ref(key)
310
330
  elif isinstance(key, slice):
311
- key_range = range(*key.indices(saved_len))
331
+ key_range = range(*key.indices(len(self)))
312
332
  return [self._file_ref(i) for i in key_range]
313
333
  else:
314
334
  raise TypeError(
@@ -5,8 +5,8 @@
5
5
  # Copyright (c) Beamline Control Unit, ESRF
6
6
  # Distributed under the GNU LGPLv3. See LICENSE for more info.
7
7
 
8
- """Utility functions to read Lima images from file or device server.
9
- """
8
+ """Utility functions to read Lima images from file or device server."""
9
+
10
10
  import struct
11
11
  import numpy
12
12
  import typing
@@ -16,7 +16,6 @@ from dataclasses import dataclass
16
16
  from blissdata import DataStore, Scan, ScanState
17
17
  from blissdata.exceptions import NoScanAvailable, ScanNotFoundError, ScanLoadError
18
18
 
19
-
20
19
  memory_tracking_stream = "_MEMORY_TRACKING_"
21
20
  SEAL_ID = ("-".join([str(2**64 - 1)] * 2)).encode()
22
21
 
@@ -35,6 +34,7 @@ class NoFreeableMemoryError(Exception):
35
34
  class TrackedScan:
36
35
  scan: Scan
37
36
  last_activity: int
37
+ last_streams_ids: dict[str, int]
38
38
 
39
39
  def __eq__(self, other):
40
40
  return self.scan.key == other.scan.key
@@ -93,7 +93,7 @@ class MemoryTracker:
93
93
  scan._model.state_stream.key(), count=1
94
94
  )
95
95
  last_activity = int(raw[0][0].split(b"-")[0]) / 1000
96
- self._tracked_scans.add(TrackedScan(scan, last_activity))
96
+ self._tracked_scans.add(TrackedScan(scan, last_activity, {}))
97
97
  except ScanNotFoundError:
98
98
  # scan already deleted from Redis by user, skip it
99
99
  pass
@@ -113,7 +113,7 @@ class MemoryTracker:
113
113
  try:
114
114
  scan = self._data_store.load_scan(scan_key)
115
115
  last_activity = int(since.split("-")[0]) / 1000
116
- self._tracked_scans.add(TrackedScan(scan, last_activity))
116
+ self._tracked_scans.add(TrackedScan(scan, last_activity, {}))
117
117
  self._since = since
118
118
  logging.info(f"Start tracking {scan.key}")
119
119
  except ScanNotFoundError:
@@ -135,7 +135,14 @@ class MemoryTracker:
135
135
 
136
136
  if track.scan.state >= ScanState.PREPARED:
137
137
  streams_ids = self._data_streams_ids(track.scan)
138
- if streams_ids:
138
+ stream_activity = {
139
+ key: idx
140
+ for key, idx in streams_ids.items()
141
+ if key not in track.last_streams_ids
142
+ or track.last_streams_ids[key] != idx
143
+ }
144
+ if stream_activity:
145
+ track.last_streams_ids = streams_ids
139
146
  track.last_activity, _ = self._data_store._redis.time()
140
147
  all_data_streams_ids.update(streams_ids)
141
148
 
@@ -43,7 +43,7 @@ class DataStore:
43
43
  close_func = type(redis_pool).disconnect
44
44
  weakref.finalize(self, close_func, redis_pool)
45
45
 
46
- class StreamModel(HashModel):
46
+ class StreamModel(HashModel, index=True):
47
47
  """Actual stream model to be embedded into ScanModel, but not exposed to user directly.
48
48
  They are wrapped into Stream objects instead."""
49
49
 
@@ -60,7 +60,7 @@ class DataStore:
60
60
  encoding: dict | str | None = None
61
61
  info: dict | str | None = None
62
62
 
63
- class ScanModel(JsonModel):
63
+ class ScanModel(JsonModel, index=True):
64
64
  class Meta:
65
65
  global_key_prefix = identity_model_cls._meta.global_key_prefix
66
66
  model_key_prefix = "scan"
@@ -7,7 +7,6 @@
7
7
  import typing
8
8
  import typing_extensions
9
9
 
10
-
11
10
  ChannelRef = str
12
11
  """Identify a channel from the `scan_info["channels"]` field"""
13
12
 
@@ -28,8 +28,8 @@ from blissdata.lima.image_utils import ImageData
28
28
 
29
29
 
30
30
  class LimaView(BaseView):
31
- def __init__(self, client, start, stop):
32
- self._client = client
31
+ def __init__(self, stream: "LimaStream", start: int, stop: int) -> None:
32
+ self._stream = stream
33
33
  self._view_range = range(start, stop)
34
34
 
35
35
  @property
@@ -41,13 +41,13 @@ class LimaView(BaseView):
41
41
 
42
42
  def get_data(self, start=None, stop=None):
43
43
  trimmed_range = self._view_range[start:stop]
44
- return self._client[trimmed_range.start : trimmed_range.stop]
44
+ return self._stream[trimmed_range.start : trimmed_range.stop]
45
45
 
46
46
  def get_references(
47
47
  self, start=None, stop=None
48
48
  ) -> ImageReference | list[ImageReference]:
49
49
  trimmed_range = self._view_range[start:stop]
50
- return self._client.get_references(
50
+ return self._stream.get_references(
51
51
  slice(trimmed_range.start, trimmed_range.stop)
52
52
  )
53
53
 
@@ -67,10 +67,16 @@ class LimaStream(BaseStream, LimaDirectAccess):
67
67
  def __init__(self, event_stream):
68
68
  super().__init__(event_stream)
69
69
  self._client = LimaClient(
70
- event_stream._data_store, **event_stream.info["lima_info"]
70
+ data_store=event_stream._data_store,
71
+ request_update_cb=self._force_update_client,
72
+ **event_stream.info["lima_info"],
71
73
  )
72
74
  self._cursor = Stream(event_stream).cursor()
73
75
 
76
+ def _force_update_client(self):
77
+ _, status_list, sealed = self._event_stream._read_last(0)
78
+ self._client.update(**status_list[0], sealed=sealed)
79
+
74
80
  @staticmethod
75
81
  def make_definition(
76
82
  name: str,
@@ -148,28 +154,41 @@ class LimaStream(BaseStream, LimaDirectAccess):
148
154
  self._update_client()
149
155
  return len(self._client)
150
156
 
151
- def _update_client(self):
157
+ def _update_client_if_needed(self, key: int | slice):
158
+ if isinstance(key, slice):
159
+ need_update = key.stop is None or not (0 <= key.stop <= len(self._client))
160
+ else:
161
+ need_update = not (0 <= key < len(self._client))
162
+ if not need_update:
163
+ return
164
+
152
165
  try:
153
166
  view = self._cursor.read(block=False, last_only=True)
154
167
  except EndOfStream:
155
- return
156
- if view is not None:
168
+ view = None
169
+ # use ._seal instead of .is_sealed() to skip redis query and because
170
+ # we just updated it by reading cursor.
171
+ sealed = self._event_stream._seal is not None
172
+ if view is None:
173
+ self._client.update(sealed=sealed)
174
+ else:
157
175
  last_status = view.get_data(-1, None)[0]
158
- self._client.update(**last_status)
176
+ self._client.update(**last_status, sealed=sealed)
159
177
 
160
178
  def __getitem__(self, key):
161
- if isinstance(key, slice):
162
- need_update = key.stop is None or not (0 <= key.stop < len(self._client))
163
- else:
164
- need_update = not (0 <= key < len(self._client))
179
+ return self._get_from_client(key, reference_mode=False)
165
180
 
166
- if need_update:
167
- self._update_client()
181
+ def get_references(self, key) -> ImageReference | list[ImageReference]:
182
+ return self._get_from_client(key, reference_mode=True)
168
183
 
184
+ def _get_from_client(self, key, reference_mode):
185
+ self._update_client_if_needed(key)
169
186
  try:
170
- return self._client[key]
187
+ if reference_mode:
188
+ return self._client.get_references(key)
189
+ else:
190
+ return self._client[key]
171
191
  except IndexError:
172
- # TODO could be verified before asking the client
173
192
  index = key.start if isinstance(key, slice) else key
174
193
  if index is None:
175
194
  index = 0
@@ -177,7 +196,7 @@ class LimaStream(BaseStream, LimaDirectAccess):
177
196
  index += len(self._client)
178
197
 
179
198
  if index >= len(self._client):
180
- if self._event_stream.is_sealed():
199
+ if self._client._stream_sealed:
181
200
  raise IndexWontBeThereError
182
201
  else:
183
202
  raise IndexNotYetThereError
@@ -189,7 +208,7 @@ class LimaStream(BaseStream, LimaDirectAccess):
189
208
  return True
190
209
 
191
210
  def _build_view_from_events(self, index: int, events: EventRange, last_only: bool):
192
- self._client.update(**events.data[-1])
211
+ self._client.update(**events.data[-1], sealed=events.end_of_stream)
193
212
 
194
213
  if len(self._client) <= index:
195
214
  # no new images despite client update
@@ -199,34 +218,7 @@ class LimaStream(BaseStream, LimaDirectAccess):
199
218
  start = len(self._client) - 1
200
219
  else:
201
220
  start = index
202
- return LimaView(self._client, start, len(self._client))
203
-
204
- def get_references(self, key) -> ImageReference | list[ImageReference]:
205
- if isinstance(key, slice):
206
- need_update = key.stop is None or not (0 <= key.stop < len(self._client))
207
- else:
208
- need_update = not (0 <= key < len(self._client))
209
-
210
- if need_update:
211
- self._update_client()
212
-
213
- try:
214
- return self._client.get_references(key)
215
- except IndexError:
216
- # TODO could be verified before asking the client
217
- index = key.start if isinstance(key, slice) else key
218
- if index is None:
219
- index = 0
220
- elif index < 0:
221
- index += len(self._client)
222
-
223
- if index >= len(self._client):
224
- if self._event_stream.is_sealed():
225
- raise IndexWontBeThereError
226
- else:
227
- raise IndexNotYetThereError
228
- else:
229
- raise IndexNoMoreThereError
221
+ return LimaView(self, start, len(self._client))
230
222
 
231
223
  def get_last_live_image(self) -> ImageData:
232
224
  return self._client.get_last_live_image()
@@ -23,7 +23,7 @@ def test_param_constructor(mocker):
23
23
 
24
24
 
25
25
  def test_get_file(mocker):
26
- RESPONSE1 = b"\x34\x00\x00\x00\x0E\x00\x00\x001|MY FILE DATA"
26
+ RESPONSE1 = b"\x34\x00\x00\x00\x0e\x00\x00\x001|MY FILE DATA"
27
27
  mock_socket = mocker.patch("socket.socket")
28
28
  mock_socket.return_value.recv.side_effect = [RESPONSE1]
29
29
  service = BeaconFiles("foo", 200)
@@ -35,7 +35,7 @@ def test_get_file(mocker):
35
35
 
36
36
 
37
37
  def test_get_tree(mocker):
38
- RESPONSE1 = b"""\x58\x00\x00\x00\x2C\x00\x00\x001|{"afile":null,"adir":{"anotherfile":null}}"""
38
+ RESPONSE1 = b"""\x58\x00\x00\x00\x2c\x00\x00\x001|{"afile":null,"adir":{"anotherfile":null}}"""
39
39
  mock_socket = mocker.patch("socket.socket")
40
40
  mock_socket.return_value.recv.side_effect = [RESPONSE1]
41
41
  service = BeaconFiles("foo", 200)
@@ -5,7 +5,6 @@ from pytest_redis.factories.proc import redis_proc
5
5
 
6
6
  from blissdata import DataStore
7
7
 
8
-
9
8
  redis_db = redis_proc(
10
9
  modules=[
11
10
  os.path.join(os.getenv("CONDA_PREFIX", "/usr"), "lib", "librejson.so"),