blissdata 2.1.0__tar.gz → 2.2.0__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 (98) hide show
  1. {blissdata-2.1.0/src/blissdata.egg-info → blissdata-2.2.0}/PKG-INFO +1 -1
  2. {blissdata-2.1.0 → blissdata-2.2.0}/pyproject.toml +1 -1
  3. blissdata-2.2.0/src/blissdata/h5api/h5_scan_map.py +133 -0
  4. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/h5api/redis_hdf5.py +238 -99
  5. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/h5map/data_items.py +1 -1
  6. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/redis_engine/scan.py +44 -26
  7. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/streams/base.py +51 -7
  8. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/streams/lima2/stream.py +18 -5
  9. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/conftest.py +9 -0
  10. blissdata-2.2.0/src/blissdata/tests/h5api/test_redis_files.py +485 -0
  11. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/h5map/test_data_items.py +1 -0
  12. blissdata-2.2.0/src/blissdata/tests/plugin_streams/test_plugin_base.py +112 -0
  13. {blissdata-2.1.0 → blissdata-2.2.0/src/blissdata.egg-info}/PKG-INFO +1 -1
  14. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata.egg-info/SOURCES.txt +2 -1
  15. blissdata-2.1.0/src/blissdata/h5api/scan_mapping.py +0 -295
  16. blissdata-2.1.0/src/blissdata/tests/h5api/test_redis_files.py +0 -287
  17. {blissdata-2.1.0 → blissdata-2.2.0}/LICENSE +0 -0
  18. {blissdata-2.1.0 → blissdata-2.2.0}/README.md +0 -0
  19. {blissdata-2.1.0 → blissdata-2.2.0}/setup.cfg +0 -0
  20. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/__init__.py +0 -0
  21. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/beacon/__init__.py +0 -0
  22. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/beacon/_base.py +0 -0
  23. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/beacon/_utils.py +0 -0
  24. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/beacon/config.py +0 -0
  25. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/beacon/data.py +0 -0
  26. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/beacon/files.py +0 -0
  27. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/h5api/__init__.py +0 -0
  28. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/h5api/abstract.py +0 -0
  29. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/h5api/dynamic_hdf5.py +0 -0
  30. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/h5api/file_arguments.py +0 -0
  31. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/h5api/static_hdf5.py +0 -0
  32. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/h5api/utils/__init__.py +0 -0
  33. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/h5api/utils/bliss.py +0 -0
  34. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/h5api/utils/hdf5.py +0 -0
  35. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/h5api/utils/hdf5_retry.py +0 -0
  36. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/h5api/utils/lima.py +0 -0
  37. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/h5api/utils/types.py +0 -0
  38. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/h5map/__init__.py +0 -0
  39. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/h5map/hdf5_items.py +0 -0
  40. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/lima/__init__.py +0 -0
  41. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/lima/client.py +0 -0
  42. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/lima/image_utils.py +0 -0
  43. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/py.typed +0 -0
  44. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/redis_engine/__init__.py +0 -0
  45. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/redis_engine/encoding/__init__.py +0 -0
  46. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/redis_engine/encoding/json.py +0 -0
  47. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/redis_engine/encoding/numeric.py +0 -0
  48. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/redis_engine/event_stream.py +0 -0
  49. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/redis_engine/exceptions.py +0 -0
  50. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/redis_engine/identities.py +0 -0
  51. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/redis_engine/memory_tracker.py +0 -0
  52. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/redis_engine/sink.py +0 -0
  53. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/redis_engine/store.py +0 -0
  54. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/scan.py +0 -0
  55. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/schemas/__init__.py +0 -0
  56. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/schemas/scan_info.py +0 -0
  57. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/schemas/scan_info_display.py +0 -0
  58. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/streams/__init__.py +0 -0
  59. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/streams/hdf5_fallback/__init__.py +0 -0
  60. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/streams/hdf5_fallback/stream.py +0 -0
  61. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/streams/lima/__init__.py +0 -0
  62. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/streams/lima/stream.py +0 -0
  63. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/streams/lima2/__init__.py +0 -0
  64. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/streams/scan_sequence/__init__.py +0 -0
  65. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/streams/scan_sequence/stream.py +0 -0
  66. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/__init__.py +0 -0
  67. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/beacon/__init__.py +0 -0
  68. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/beacon/test_config.py +0 -0
  69. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/beacon/test_data.py +0 -0
  70. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/beacon/test_files.py +0 -0
  71. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/h5api/__init__.py +0 -0
  72. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/h5api/scanner.py +0 -0
  73. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/h5api/test_dynamic_files.py +0 -0
  74. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/h5api/test_h5api_lima_utils.py +0 -0
  75. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/h5api/test_hdf5_handlers.py +0 -0
  76. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/h5api/test_static_files.py +0 -0
  77. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/h5map/__init__.py +0 -0
  78. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/h5map/test_hdf5_items.py +0 -0
  79. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/lima/__init__.py +0 -0
  80. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/lima/test_image_utils.py +0 -0
  81. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/plugin_streams/__init__.py +0 -0
  82. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/plugin_streams/test_file_backed_stream.py +0 -0
  83. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/plugin_streams/test_lima2_stream.py +0 -0
  84. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/redis_engine/__init__.py +0 -0
  85. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/redis_engine/conftest.py +0 -0
  86. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/redis_engine/encoding/__init__.py +0 -0
  87. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/redis_engine/encoding/test_json.py +0 -0
  88. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/redis_engine/encoding/test_numeric.py +0 -0
  89. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/redis_engine/test_cursor.py +0 -0
  90. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/redis_engine/test_event_stream.py +0 -0
  91. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/redis_engine/test_memory_tracker.py +0 -0
  92. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/redis_engine/test_scan.py +0 -0
  93. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/redis_engine/test_search.py +0 -0
  94. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata/tests/redis_engine/utils.py +0 -0
  95. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata.egg-info/dependency_links.txt +0 -0
  96. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata.egg-info/entry_points.txt +0 -0
  97. {blissdata-2.1.0 → blissdata-2.2.0}/src/blissdata.egg-info/requires.txt +0 -0
  98. {blissdata-2.1.0 → blissdata-2.2.0}/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.1.0
3
+ Version: 2.2.0
4
4
  Summary: Bliss data streaming client
5
5
  Maintainer: BCU (ESRF)
6
6
  License-Expression: LGPL-3.0-or-later
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "blissdata"
3
- version = "2.1.0"
3
+ version = "2.2.0"
4
4
  description = "Bliss data streaming client"
5
5
  readme = "README.md"
6
6
  license = "LGPL-3.0-or-later"
@@ -0,0 +1,133 @@
1
+ import time
2
+ import logging
3
+ from collections.abc import Mapping
4
+ from pathlib import PurePosixPath as Path
5
+ from blissdata.redis_engine.scan import ScanState
6
+ from blissdata.h5map import HDF5ItemType, Group
7
+
8
+ _logger = logging.getLogger(__name__)
9
+
10
+
11
+ class H5ScanMap(Mapping):
12
+ """H5ScanMap is a wrapper around a blissdata's Scan. It exposes its HDF5
13
+ mapping with conventional dict API, but also handles automatic refresh of
14
+ the underlying model from Redis (see _updated_root method).
15
+
16
+ IMPORTANT: This is not an H5-like object, it helps manipulating the mapping
17
+ within a scan. Then H5-like groups and datasets can be implemented on top of
18
+ it."""
19
+
20
+ def __init__(self, scan):
21
+ self._scan = scan
22
+ self._root = None
23
+ self._last_update = time.perf_counter()
24
+
25
+ @property
26
+ def scan(self):
27
+ return self._scan
28
+
29
+ def __len__(self):
30
+ return len(self._updated_root().children)
31
+
32
+ def __iter__(self):
33
+ yield from self._updated_root().children.keys()
34
+
35
+ def __getitem__(self, path: str) -> HDF5ItemType:
36
+ """Return the HDF5Item associated to that path.
37
+ Links are not resolved here, instead a SoftLink or ExternalLink is
38
+ returned and it is caller choice to resolve it or not.
39
+ """
40
+ assert path.startswith("/")
41
+ h5_item = self._updated_root()
42
+ current_path = Path("/")
43
+ for part in Path(path).parts:
44
+ if part == "/":
45
+ continue
46
+ current_path /= part
47
+ if not isinstance(h5_item, Group):
48
+ raise KeyError(str(current_path))
49
+ try:
50
+ h5_item = h5_item.children[part]
51
+ except KeyError:
52
+ raise KeyError(str(current_path))
53
+ return h5_item
54
+
55
+ def path_partition(self, path: str) -> tuple[str, HDF5ItemType | None, str]:
56
+ """Similar to __getitem__, but may return partially resolved path.
57
+ Goal is to allow link items to be returned along with the remaining
58
+ part of the path. Links can be resolved before going further.
59
+
60
+ Returns:
61
+ tuple:
62
+ - path of the returned item
63
+ - h5map item or None if path doesn't exist
64
+ - remaining path (empty if item is the actual target)
65
+
66
+ Example:
67
+ /
68
+ ├─a
69
+ │ ├─b (link to d)
70
+ │ └─c
71
+ └─d
72
+ └─e
73
+
74
+ path_partition("/a/b/c") -> "/a/b/c", Dataset(...), ""
75
+ path_partition("/a/b/e") -> "/a/b", SoftLink("/d"), "e"
76
+ path_partition("/a/x/y") -> "/a/x", None, "y"
77
+ """
78
+ assert path.startswith("/")
79
+ h5_item = self._updated_root()
80
+ item_parts = ["/"]
81
+ remaining_parts = list(Path(path).parts[1:])
82
+ while remaining_parts:
83
+ if isinstance(h5_item, Group):
84
+ try:
85
+ item_parts.append(remaining_parts.pop(0))
86
+ h5_item = h5_item.children[item_parts[-1]]
87
+ except KeyError:
88
+ h5_item = None
89
+ break
90
+ else:
91
+ break
92
+ remaining_path = str(Path(*remaining_parts)) if remaining_parts else ""
93
+ return str(Path(*item_parts)), h5_item, remaining_path
94
+
95
+ def _updated_root(self) -> Group:
96
+ """Return an h5map.Group loaded from scan info. Reuse previous answer if
97
+ not older than 0.2 seconds (polling Redis to 5Hz at most if fine).
98
+ Moreover, scan state doesn't change that much and the scan's json is
99
+ only downloaded on changes.
100
+
101
+ In case the scan provides no mapping, an empty Group is returned.
102
+ """
103
+ scan_state_changed = False
104
+
105
+ # Mapping is not expected to be published before scan is prepared, wait
106
+ while self._scan.state < ScanState.PREPARED:
107
+ self._scan.update()
108
+ self._last_update = time.perf_counter()
109
+ scan_state_changed = True
110
+
111
+ # Update whenever the scan is not CLOSED and the last_update is not too
112
+ # recent.
113
+ min_update_interval = 0.2
114
+ now = time.perf_counter()
115
+ if (
116
+ self._last_update + min_update_interval < now
117
+ and self._scan.state < ScanState.CLOSED
118
+ ):
119
+ scan_state_changed = self._scan.update(block=False)
120
+ self._last_update = time.perf_counter()
121
+
122
+ # (re)generate mapping tree if necessary
123
+ if scan_state_changed or self._root is None:
124
+ try:
125
+ mappings = self._scan.info.get("h5maps", {})
126
+ self._root = Group.model_validate(mappings[self._scan.path])
127
+ except KeyError:
128
+ _logger.warning(
129
+ f"Scan {self._scan.number} has no hdf5 mapping ({self._scan.key})"
130
+ )
131
+ return Group()
132
+
133
+ return self._root
@@ -1,20 +1,17 @@
1
1
  import re
2
2
  import time
3
+ import math
3
4
  import functools
4
5
  import numpy as np
6
+ from abc import ABC, abstractmethod
5
7
  from pathlib import PurePosixPath as Path
6
8
 
9
+ import blissdata.h5map as h5m
7
10
  from blissdata.beacon.data import BeaconData
8
11
  from blissdata.redis_engine.store import DataStore
9
- from blissdata.h5api import abstract
10
- from blissdata.h5api.scan_mapping import (
11
- MappingNode,
12
- GroupNode,
13
- DatasetNode,
14
- ScanMapper,
15
- SoftLinkNode,
16
- )
17
12
  from blissdata.redis_engine.exceptions import EndOfStream, IndexNotYetThereError
13
+ from blissdata.h5api import abstract
14
+ from blissdata.h5api.h5_scan_map import H5ScanMap
18
15
 
19
16
 
20
17
  class Attributes(abstract.Attributes):
@@ -41,9 +38,13 @@ class File(abstract.File):
41
38
  file can iterate on live data.
42
39
  """
43
40
 
44
- def __init__(self, filepath: str, data_store: DataStore | None = None):
41
+ def __init__(
42
+ self, filepath: str, mode: str = "r", data_store: DataStore | None = None
43
+ ):
45
44
  """If no data_store is provided, but $BEACON_HOST variable is defined,
46
45
  then try to query Redis address from that beacon server."""
46
+ if mode != "r":
47
+ raise ValueError("Invalid mode; only r is supported with live mode")
47
48
  self._filepath = Path(filepath)
48
49
  self._closed = False
49
50
  if data_store is None:
@@ -81,58 +82,67 @@ class File(abstract.File):
81
82
  self._closed = True
82
83
 
83
84
  def __repr__(self):
84
- return f'<{self.__module__}.{type(self).__name__} "{self._filepath.name}">'
85
+ return f'<Live-HDF5 file "{self._filepath.name}" (mode r)>'
85
86
 
86
87
  def __len__(self):
87
88
  """Return the instant length from the scans that are already there."""
88
89
  return len(self.keys())
89
90
 
90
91
  def __getitem__(self, path: str):
91
- scan_path, sub_path = self._split_path(path)
92
- path = Path("/") / path
93
- if scan_path == "/":
94
- return Group(self, path, self.attrs)
95
-
96
- try:
97
- scan_mapper = self._get_scan_mapper_by_path(scan_path)
98
- node = scan_mapper[str(path)]
99
- except KeyError:
100
- raise KeyError(f"No such path: {path}")
92
+ # save originally requested path
93
+ original_path = path
101
94
 
95
+ # track visited paths to detect link cycles
102
96
  visited_paths = []
103
- while isinstance(node, SoftLinkNode):
104
- visited_paths.append(path)
105
- path = Path(node.path).parent / node.target
106
- if path in visited_paths:
97
+ while path:
98
+ path = Path("/") / path
99
+ if str(path) in visited_paths:
107
100
  raise KeyError(
108
- f"Link can't be resolved, found cyclic links: {[str(p) for p in visited_paths]}"
101
+ f"'{original_path}': path can't be resolved, found link cycle: {[str(p) for p in visited_paths]}"
109
102
  )
103
+ else:
104
+ visited_paths.append(str(path))
105
+
106
+ scan_path = self._scan_path(path)
107
+ if scan_path == "/":
108
+ return Group(self, "/", self.attrs)
109
+
110
110
  try:
111
- node = scan_mapper[str(path)]
111
+ scan_map = self._get_scan_map_by_path(scan_path)
112
112
  except KeyError:
113
- raise KeyError(f"Broken link, no such path: {path}")
114
-
115
- realpath = node.path # if softlink where encountered
116
- return self._mapping_node_to_h5(realpath, node, scan_mapper.scan)
117
-
118
- def _mapping_node_to_h5(self, path: str, node: MappingNode, scan):
119
- if isinstance(node, GroupNode):
120
- return Group(self, str(path), node.attrs)
121
- elif isinstance(node, DatasetNode):
122
- if node.value is not None:
123
- return StaticDataset(
124
- self, str(path), attrs=node.attrs, value=node.value
125
- )
126
- elif node.stream is not None:
127
- return StreamDataset(
128
- self, str(path), attrs=node.attrs, stream=scan.streams[node.stream]
113
+ raise KeyError(f"No such path: '{scan_path}'")
114
+
115
+ item_path, item, remain_path = scan_map.path_partition(str(path))
116
+ if item is None:
117
+ raise KeyError(f"No such path: '{item_path}'")
118
+
119
+ if isinstance(item, h5m.SoftLink):
120
+ path = Path(item_path).parent / item.target_path / remain_path
121
+ elif isinstance(item, h5m.ExternalLink):
122
+ # NOTE no protection against link cycles involving multiple files
123
+ return File(item.target_file, data_store=self._data_store)[
124
+ str(Path(item.target_path) / remain_path)
125
+ ]
126
+ elif remain_path:
127
+ raise KeyError(
128
+ f"'{item_path}' is not a Group, can't reach '{str(path)}'"
129
129
  )
130
130
  else:
131
- raise TypeError(f"Unknown dataset type {node}")
132
- elif isinstance(node, SoftLinkNode):
133
- raise TypeError("Oops, unresolved SoftLinkNode should not end up there")
131
+ return self._map_item_to_h5(str(path), item, scan_map.scan)
132
+
133
+ def _map_item_to_h5(self, path: str, item: h5m.HDF5ItemType, scan):
134
+ if isinstance(item, h5m.Group):
135
+ return Group(self, path, item.attributes)
136
+ elif isinstance(
137
+ item, (h5m.Dataset, h5m.ExternalBinaryDataset, h5m.VirtualDataset)
138
+ ):
139
+ return dataset_factory(self, path, item, scan)
140
+ elif isinstance(item, h5m.SoftLink):
141
+ raise TypeError("Oops, unresolved h5m.SoftLink should not end up there")
142
+ elif isinstance(item, h5m.ExternalLink):
143
+ raise TypeError("Oops, unresolved h5m.ExternalLink should not end up there")
134
144
  else:
135
- raise TypeError(f"Unknown node type {type(node).__name__}")
145
+ raise TypeError(f"Unknown item type {type(item).__name__}")
136
146
 
137
147
  def __iter__(self) -> str:
138
148
  """Iterate forever: keep waiting for new scans once existing ones are
@@ -140,7 +150,7 @@ class File(abstract.File):
140
150
  # iterate through existing scans in that file_path
141
151
  ts, keys = self._data_store.search_existing_scans(path=self.filename)
142
152
  for key in keys:
143
- yield from self._get_scan_mapper_by_key(key)
153
+ yield from self._get_scan_map_by_key(key)
144
154
 
145
155
  # iterate forever over the next scans
146
156
  while True:
@@ -150,7 +160,7 @@ class File(abstract.File):
150
160
  # outside of blissdata (a proper API should be discussed if needed)
151
161
  path = self._data_store._redis.json().get(key, "id.path")
152
162
  if path == self.filename:
153
- yield from self._get_scan_mapper_by_key(key)
163
+ yield from self._get_scan_map_by_key(key)
154
164
 
155
165
  def keys(self):
156
166
  """Instant list of keys in the file. Can be used to only iterate over
@@ -158,12 +168,12 @@ class File(abstract.File):
158
168
  _, keys = self._data_store.search_existing_scans(path=self.filename)
159
169
  ret = set()
160
170
  for key in keys:
161
- ret |= set(self._get_scan_mapper_by_key(key).keys())
171
+ ret |= set(self._get_scan_map_by_key(key).keys())
162
172
  return ret
163
173
 
164
- def _get_scan_mapper_by_path(self, scan_path: str) -> ScanMapper:
174
+ def _get_scan_map_by_path(self, scan_path: str) -> H5ScanMap:
165
175
  key = self._get_scan_key(scan_path)
166
- return self._get_scan_mapper_by_key(key)
176
+ return self._get_scan_map_by_key(key)
167
177
 
168
178
  def _get_scan_key(self, scan_path: str) -> str:
169
179
  return File._get_scan_key_cached(self._data_store, self.filename, scan_path)
@@ -187,60 +197,60 @@ class File(abstract.File):
187
197
  )
188
198
  return keys.pop()
189
199
 
190
- def _get_scan_mapper_by_key(self, key: str) -> ScanMapper:
191
- """Load a scan from Redis, wrap it into a ScanMapper and update
200
+ def _get_scan_map_by_key(self, key: str) -> H5ScanMap:
201
+ """Load a scan from Redis, wrap it into a H5ScanMap and update
192
202
  cache"""
193
- return File._get_scan_mapper_by_key_cached(self._data_store, key)
203
+ return File._get_scan_map_by_key_cached(self._data_store, key)
194
204
 
195
205
  @staticmethod
196
206
  @functools.lru_cache(maxsize=100)
197
- def _get_scan_mapper_by_key_cached(data_store: DataStore, key: str) -> ScanMapper:
207
+ def _get_scan_map_by_key_cached(data_store: DataStore, key: str) -> H5ScanMap:
198
208
  scan = data_store.load_scan(key)
199
- return ScanMapper(scan)
209
+ return H5ScanMap(scan)
200
210
 
201
211
  def _len_group(self, path: str) -> int:
202
212
  """Used by Group.__len__ to query length of a particular path (not only
203
213
  the root)"""
204
- scan_path, sub_path = self._split_path(path)
214
+ scan_path = self._scan_path(path)
205
215
  if scan_path == "/":
206
216
  return len(self)
207
217
  else:
208
218
  # number of items inside a scan
209
219
  try:
210
- scan_mapper = self._get_scan_mapper_by_path(scan_path)
211
- group_node = scan_mapper[path]
220
+ scan_map = self._get_scan_map_by_path(scan_path)
221
+ group_item = scan_map[path]
212
222
  except KeyError as e:
213
223
  raise KeyError(f"No such path: {str(scan_path / e.args[0])}")
214
- return len(group_node)
224
+ return len(group_item.children)
215
225
 
216
226
  def _iter_group(self, path: str) -> str:
217
227
  """Used by Group.__iter__ to iterate through a particular path (not
218
228
  only the root)."""
219
- scan_path, sub_path = self._split_path(path)
229
+ scan_path = self._scan_path(path)
220
230
  if scan_path == "/":
221
231
  # iterate over scans (never stops)
222
232
  yield from self
223
233
  else:
224
234
  # iterate inside a scan
225
235
  try:
226
- scan_mapper = self._get_scan_mapper_by_path(scan_path)
227
- group_node = scan_mapper[path]
236
+ scan_map = self._get_scan_map_by_path(scan_path)
237
+ group_item = scan_map[path]
228
238
  except KeyError as e:
229
239
  raise KeyError(f"No such path: {str(scan_path / e.args[0])}")
230
- yield from group_node
231
-
232
- def _split_path(self, path: str) -> tuple[str, str]:
233
- """Make path canonical and split it into scan and subscan levels
234
- '' -> '/', ''
235
- '123.1' -> '/123.1', ''
236
- '//123.1///' -> '/123.1', ''
237
- '/123.1/a/b/c' -> '/123.1', 'a/b/c'
240
+ yield from group_item.children
241
+
242
+ def _scan_path(self, path: str) -> str:
243
+ """Make path canonical and keep scan part only
244
+ '' -> '/',
245
+ '123.1' -> '/123.1',
246
+ '//123.1///' -> '/123.1',
247
+ '/123.1/a/b/c' -> '/123.1',
238
248
  """
239
249
  path = Path("/") / path
240
250
  if len(path.parts) <= 2:
241
- return str(path), ""
251
+ return str(path)
242
252
  else:
243
- return str(Path(*path.parts[:2])), str(Path(*path.parts[2:]))
253
+ return str(Path(*path.parts[:2]))
244
254
 
245
255
 
246
256
  class Node(abstract.Node):
@@ -287,7 +297,11 @@ class Group(Node, abstract.Group):
287
297
  return self._path
288
298
 
289
299
  def __repr__(self):
290
- return f'<{self.__module__}.{type(self).__name__} "{self._path}">'
300
+ try:
301
+ length = len(self)
302
+ except Exception:
303
+ length = "?"
304
+ return f'<Live-HDF5 group "{self._path}" ({length} members)>'
291
305
 
292
306
  def __getitem__(self, path: str):
293
307
  try:
@@ -304,48 +318,105 @@ class Group(Node, abstract.Group):
304
318
  return self._file._len_group(self._path)
305
319
 
306
320
 
307
- class StaticDataset(Node, abstract.Dataset):
308
- def __init__(self, file: File, path: str, value, attrs: dict):
321
+ class Dataset(Node, abstract.Dataset):
322
+ def __init__(
323
+ self,
324
+ file: File,
325
+ path: str,
326
+ attrs: dict,
327
+ array: ...,
328
+ virtual: bool = False,
329
+ external=None,
330
+ ):
309
331
  Node.__init__(self, file, path, attrs)
310
- self._value = value
332
+ self._array = array
333
+ self._virtual = virtual
334
+ self._external = external
311
335
 
312
336
  def __repr__(self):
313
- return f'<{self.__module__}.{type(self).__name__} "{self._value}">'
337
+ if isinstance(self._array, StreamAsArray):
338
+ shape = f"(_,{','.join([str(i) for i in self._array._stream.shape])})"
339
+ else:
340
+ shape = self.shape
341
+ return f'<Live-HDF5 dataset "{Path(self.name).name}": shape {shape}, type "{self.dtype.str}">'
342
+
343
+ @property
344
+ def is_virtual(self):
345
+ return self._virtual
346
+
347
+ @property
348
+ def external(self):
349
+ return self._external
314
350
 
315
351
  def __getitem__(self, idx):
316
- if idx == ():
317
- return self._value
318
- return self._value[idx]
352
+ if self.ndim == 0 and idx != ():
353
+ raise ValueError("Illegal slicing argument for scalar dataspace")
354
+ return self._array[idx]
319
355
 
320
356
  def __len__(self):
321
- return len(self._value)
357
+ if self.ndim == 0:
358
+ raise TypeError("Attempt to take len() of scalar dataset")
359
+ return len(self._array)
322
360
 
323
361
  @property
324
362
  def dtype(self):
325
- raise NotImplementedError
363
+ return self._array.dtype
326
364
 
327
365
  @property
328
366
  def shape(self):
329
- raise NotImplementedError
367
+ return self._array.shape
368
+
369
+ @property
370
+ def ndim(self):
371
+ return self._array.ndim
330
372
 
331
373
  @property
332
374
  def size(self):
333
- raise NotImplementedError
375
+ return self._array.size
334
376
 
335
377
  def __iter__(self):
336
- # TODO if scalar:
337
- # raise TypeError("Can't iterate over a scalar dataset")
338
- yield from self._value # TODO only if value is iterable
378
+ if self.ndim == 0:
379
+ raise TypeError("Can't iterate over a scalar dataset")
380
+ yield from self._array
381
+
382
+
383
+ class ArrayInterface(ABC):
384
+ @abstractmethod
385
+ def __getitem__(self, idx):
386
+ pass
387
+
388
+ @abstractmethod
389
+ def __len__(self):
390
+ pass
391
+
392
+ @property
393
+ @abstractmethod
394
+ def dtype(self):
395
+ pass
339
396
 
340
397
  @property
398
+ @abstractmethod
399
+ def shape(self):
400
+ pass
401
+
402
+ @property
403
+ @abstractmethod
341
404
  def ndim(self):
342
- raise NotImplementedError
405
+ pass
406
+
407
+ @property
408
+ @abstractmethod
409
+ def size(self):
410
+ pass
343
411
 
412
+ @abstractmethod
413
+ def __iter__(self):
414
+ pass
344
415
 
345
- class StreamDataset(Node, abstract.Dataset):
346
- def __init__(self, file: File, path: str, stream, attrs: dict):
416
+
417
+ class StreamAsArray(ArrayInterface):
418
+ def __init__(self, stream):
347
419
  assert stream.kind == "array"
348
- Node.__init__(self, file, path, attrs)
349
420
  self._stream = stream
350
421
 
351
422
  def __getitem__(self, idx):
@@ -384,22 +455,90 @@ class StreamDataset(Node, abstract.Dataset):
384
455
  def shape(self):
385
456
  return (len(self),) + self._stream.shape
386
457
 
458
+ @property
459
+ def ndim(self):
460
+ return len(self._stream.shape) + 1
461
+
387
462
  @property
388
463
  def size(self):
389
- return np.prod(self.shape)
464
+ return math.prod(self.shape)
390
465
 
391
466
  def __iter__(self):
392
467
  cursor = self._stream.cursor()
393
- timeout = 1.0
394
468
  while True:
395
469
  try:
396
- view = cursor.read(timeout=timeout)
397
- yield from view.get_data()
398
- # TODO views should be iterable to choose on their own what's
399
- # the best batch size to download (e.g. LimaView)
470
+ view = cursor.read()
471
+ yield from view
400
472
  except EndOfStream:
401
473
  break
402
474
 
475
+
476
+ class ExternalBinaryAsArray:
477
+ def __init__(self, item):
478
+ self.memmaps = []
479
+ self._shape = tuple(item.shape)
480
+ self._dtype = np.dtype(item.dtype)
481
+ for file in item.files:
482
+ if file.size is None:
483
+ shape = None
484
+ else:
485
+ shape = (file.size // self._dtype.itemsize,)
486
+ self.memmaps.append(
487
+ np.memmap(
488
+ file.name,
489
+ dtype=self._dtype,
490
+ mode="r",
491
+ offset=file.offset,
492
+ shape=shape,
493
+ )
494
+ )
495
+
496
+ # NOTE naive approach loading files in memory, but memmaps could be
497
+ # accessed on demand
498
+ self._data = np.concatenate([m for m in self.memmaps])[: self.size].reshape(
499
+ self.shape
500
+ )
501
+
502
+ def __getitem__(self, key):
503
+ return self._data[key]
504
+
505
+ def __len__(self):
506
+ return self._shape[0]
507
+
508
+ @property
509
+ def dtype(self):
510
+ return self._dtype
511
+
512
+ @property
513
+ def shape(self):
514
+ return self._shape
515
+
403
516
  @property
404
517
  def ndim(self):
405
- return len(self.shape)
518
+ return len(self._shape)
519
+
520
+ @property
521
+ def size(self):
522
+ return math.prod(self._shape)
523
+
524
+ def __iter__(self):
525
+ yield from self._data
526
+
527
+
528
+ def dataset_factory(parent: File, path: str, item: h5m.HDF5ItemType, scan) -> Dataset:
529
+ virtual = False
530
+ external = None
531
+ if isinstance(item, h5m.Dataset):
532
+ if isinstance(item.value, h5m.Stream):
533
+ array = StreamAsArray(scan.streams[item.value.stream])
534
+ else:
535
+ array = item.value.decode()
536
+ elif isinstance(item, h5m.ExternalBinaryDataset):
537
+ external = [(file.name, file.offset, file.size) for file in item.files]
538
+ array = ExternalBinaryAsArray(item)
539
+ elif isinstance(item, h5m.VirtualDataset):
540
+ virtual = True
541
+ # TODO
542
+ raise NotImplementedError
543
+
544
+ return Dataset(parent, path, item, array, virtual, external)
@@ -64,7 +64,7 @@ class InlineRaw(RootModel):
64
64
  def decode(self):
65
65
  value = self.model_dump()
66
66
  if value.dtype.kind in "UT":
67
- return value.tolist()
67
+ return np.vectorize(lambda x: x.encode(), otypes="O")(value)
68
68
  else:
69
69
  return value
70
70