Skip to content

Commit ec5cfa5

Browse files
authored
Allow for overriding read/uri location for stream resource docs. (#940)
* Start working on adding directory_uri to PathInfo * AD writers mostly modified to use directory uri * Ruff and pre commit, modify panda writer to use directory_uri * Fix failing test on linux and windows, apply suggestions from review * Add test for new directory uri override option, ensure path separator always added to filepath in AD writers * Don't use os.sep, since separator must be defined by IOC host * Fix test failures on windows * Fix failing test on windows * Append separator if it is missing * Use normalized path for error message * Make sure directory path is always absolute for AD writers * Fix failing windows test * Use PurePath, and ensure path must be absolute in PathInfo * Ruff * Minor fixes * Add tests for directory path override * Add test for different write/read path semantics to detector tests * Linter fixes * Fix typing errors * Use same type of path as base path in ymd path provider. Use isinstance for checking path type in ad core writer
1 parent d24430f commit ec5cfa5

File tree

16 files changed

+344
-88
lines changed

16 files changed

+344
-88
lines changed

src/ophyd_async/core/_hdf_dataset.py

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
from collections.abc import Iterator
2-
from pathlib import Path
3-
from urllib.parse import urlunparse
42

53
from bluesky.protocols import StreamAsset
64
from event_model import ( # type: ignore
@@ -48,22 +46,11 @@ class HDFDocumentComposer:
4846

4947
def __init__(
5048
self,
51-
full_file_name: Path,
49+
file_uri: str,
5250
datasets: list[HDFDatasetDescription],
53-
hostname: str = "localhost",
5451
) -> None:
5552
self._last_emitted = 0
56-
self._hostname = hostname
57-
uri = urlunparse(
58-
(
59-
"file",
60-
self._hostname,
61-
str(full_file_name.absolute()),
62-
"",
63-
"",
64-
None,
65-
)
66-
)
53+
uri = file_uri
6754
bundler_composer = ComposeStreamResource()
6855
self._bundles: list[ComposeStreamResourceBundle] = [
6956
bundler_composer(

src/ophyd_async/core/_providers.py

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import os
21
import uuid
32
from abc import abstractmethod
43
from collections.abc import Callable
54
from dataclasses import dataclass
65
from datetime import date
7-
from pathlib import Path
6+
from pathlib import PurePath, PureWindowsPath
87
from typing import Protocol
8+
from urllib.parse import urlunparse
99

1010

1111
@dataclass
@@ -16,11 +16,36 @@ class PathInfo:
1616
:param filename: Base filename to use generated by FilenameProvider, w/o extension
1717
:param create_dir_depth: Optional depth of directories to create if they do not
1818
exist
19+
:param directory_uri: Optional URI to use for reading back resources. If not set,
20+
it will be generated from the directory path.
1921
"""
2022

21-
directory_path: Path
23+
directory_path: PurePath
2224
filename: str
2325
create_dir_depth: int = 0
26+
directory_uri: str | None = None
27+
28+
def __post_init__(self):
29+
if not self.directory_path.is_absolute():
30+
raise ValueError(
31+
f"directory_path must be an absolute path, got {self.directory_path}"
32+
)
33+
34+
# If directory uri is not set, set it using the directory path.
35+
if self.directory_uri is None:
36+
self.directory_uri = urlunparse(
37+
(
38+
"file",
39+
"localhost",
40+
f"{self.directory_path.as_posix()}/",
41+
"",
42+
"",
43+
None,
44+
)
45+
)
46+
elif not self.directory_uri.endswith("/"):
47+
# Ensure the directory URI ends with a slash.
48+
self.directory_uri += "/"
2449

2550

2651
class FilenameProvider(Protocol):
@@ -112,18 +137,21 @@ class StaticPathProvider(PathProvider):
112137
def __init__(
113138
self,
114139
filename_provider: FilenameProvider,
115-
directory_path: Path | str,
140+
directory_path: PurePath,
141+
directory_uri: str | None = None,
116142
create_dir_depth: int = 0,
117143
) -> None:
118144
self._filename_provider = filename_provider
119-
self._directory_path = Path(directory_path)
145+
self._directory_path = directory_path
146+
self._directory_uri = directory_uri
120147
self._create_dir_depth = create_dir_depth
121148

122149
def __call__(self, device_name: str | None = None) -> PathInfo:
123150
filename = self._filename_provider(device_name)
124151

125152
return PathInfo(
126153
directory_path=self._directory_path,
154+
directory_uri=self._directory_uri,
127155
filename=filename,
128156
create_dir_depth=self._create_dir_depth,
129157
)
@@ -135,7 +163,8 @@ class AutoIncrementingPathProvider(PathProvider):
135163
def __init__(
136164
self,
137165
filename_provider: FilenameProvider,
138-
base_directory_path: Path,
166+
base_directory_path: PurePath,
167+
base_directory_uri: str | None = None,
139168
create_dir_depth: int = 0,
140169
max_digits: int = 5,
141170
starting_value: int = 0,
@@ -146,6 +175,12 @@ def __init__(
146175
) -> None:
147176
self._filename_provider = filename_provider
148177
self._base_directory_path = base_directory_path
178+
self._base_directory_uri = base_directory_uri
179+
if (
180+
self._base_directory_uri is not None
181+
and not self._base_directory_uri.endswith("/")
182+
):
183+
self._base_directory_uri += "/"
149184
self._create_dir_depth = create_dir_depth
150185
self._base_name = base_name
151186
self._starting_value = starting_value
@@ -174,8 +209,13 @@ def __call__(self, device_name: str | None = None) -> PathInfo:
174209
self._inc_counter = 0
175210
self._current_value += self._increment
176211

212+
directory_uri = None
213+
if self._base_directory_uri is not None:
214+
directory_uri = f"{self._base_directory_uri}{auto_inc_dir_name}"
215+
177216
return PathInfo(
178217
directory_path=self._base_directory_path / auto_inc_dir_name,
218+
directory_uri=directory_uri,
179219
filename=filename,
180220
create_dir_depth=self._create_dir_depth,
181221
)
@@ -187,34 +227,52 @@ class YMDPathProvider(PathProvider):
187227
def __init__(
188228
self,
189229
filename_provider: FilenameProvider,
190-
base_directory_path: Path,
230+
base_directory_path: PurePath,
231+
base_directory_uri: str | None = None,
191232
create_dir_depth: int = -3, # Default to -3 to create YMD dirs
192233
device_name_as_base_dir: bool = False,
193234
) -> None:
194235
self._filename_provider = filename_provider
195-
self._base_directory_path = Path(base_directory_path)
236+
self._base_directory_path = base_directory_path
237+
self._base_directory_uri = base_directory_uri
238+
if (
239+
self._base_directory_uri is not None
240+
and not self._base_directory_uri.endswith("/")
241+
):
242+
self._base_directory_uri += "/"
196243
self._create_dir_depth = create_dir_depth
197244
self._device_name_as_base_dir = device_name_as_base_dir
198245

199246
def __call__(self, device_name: str | None = None) -> PathInfo:
200-
sep = os.path.sep
247+
path_type = type(self._base_directory_path)
248+
if path_type == PureWindowsPath:
249+
sep = "\\"
250+
else:
251+
sep = "/"
252+
201253
current_date = date.today().strftime(f"%Y{sep}%m{sep}%d")
202254
if device_name is None:
203255
ymd_dir_path = current_date
204256
elif self._device_name_as_base_dir:
205-
ymd_dir_path = os.path.join(
257+
ymd_dir_path = path_type(
206258
current_date,
207259
device_name,
208260
)
209261
else:
210-
ymd_dir_path = os.path.join(
262+
ymd_dir_path = path_type(
211263
device_name,
212264
current_date,
213265
)
214266

215267
filename = self._filename_provider(device_name)
268+
269+
directory_uri = None
270+
if self._base_directory_uri is not None:
271+
directory_uri = f"{self._base_directory_uri}{ymd_dir_path}"
272+
216273
return PathInfo(
217274
directory_path=self._base_directory_path / ymd_dir_path,
275+
directory_uri=directory_uri,
218276
filename=filename,
219277
create_dir_depth=self._create_dir_depth,
220278
)

src/ophyd_async/epics/adcore/_core_writer.py

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import asyncio
22
from collections.abc import AsyncGenerator, AsyncIterator
3-
from pathlib import Path
3+
from pathlib import PureWindowsPath
44
from typing import Generic, TypeVar, get_args
5-
from urllib.parse import urlunparse
65

76
from bluesky.protocols import Hints, StreamAsset
87
from event_model import ( # type: ignore
@@ -13,14 +12,14 @@
1312
from pydantic import PositiveInt
1413

1514
from ophyd_async.core._detector import DetectorWriter
16-
from ophyd_async.core._providers import DatasetDescriber, PathProvider
15+
from ophyd_async.core._providers import DatasetDescriber, PathInfo, PathProvider
1716
from ophyd_async.core._signal import (
1817
observe_value,
1918
set_and_wait_for_value,
2019
wait_for_value,
2120
)
2221
from ophyd_async.core._status import AsyncStatus
23-
from ophyd_async.core._utils import DEFAULT_TIMEOUT
22+
from ophyd_async.core._utils import DEFAULT_TIMEOUT, error_if_none
2423

2524
# from ophyd_async.epics.adcore._core_logic import ADBaseDatasetDescriber
2625
from ._core_io import (
@@ -53,7 +52,8 @@ def __init__(
5352
) -> None:
5453
self._plugins = plugins or {}
5554
self.fileio = fileio
56-
self._path_provider = path_provider
55+
self._path_provider: PathProvider = path_provider
56+
self._path_info: PathInfo | None = None
5757
self._dataset_describer = dataset_describer
5858
self._file_extension = file_extension
5959
self._mimetype = mimetype
@@ -83,20 +83,32 @@ def with_io(
8383
writer = cls(fileio, path_provider, dataset_describer, plugins=plugins)
8484
return writer
8585

86-
async def begin_capture(self, name: str) -> None:
87-
info = self._path_provider(device_name=name)
86+
async def _begin_capture(self, name: str) -> None:
87+
path_info = error_if_none(
88+
self._path_info, "Writer must be opened before beginning capture!"
89+
)
8890

8991
if isinstance(self.fileio, NDFilePluginIO):
9092
await self.fileio.enable_callbacks.set(ADCallbacks.ENABLE)
9193

9294
# Set the directory creation depth first, since dir creation callback happens
9395
# when directory path PV is processed.
94-
await self.fileio.create_directory.set(info.create_dir_depth)
96+
await self.fileio.create_directory.set(path_info.create_dir_depth)
97+
98+
# Need to ensure that trailing separator is added to the directory path.
99+
# When setting the path for windows based AD IOCs, a '/' is added rather than
100+
# a '\\', which will cause the readback to never register the same value.
101+
dir_path_as_str = str(path_info.directory_path)
102+
separator = "/"
103+
if isinstance(path_info.directory_path, PureWindowsPath):
104+
separator = "\\"
105+
106+
dir_path_as_str += separator
95107

96108
await asyncio.gather(
97109
# See https://github.com/bluesky/ophyd-async/issues/122
98-
self.fileio.file_path.set(str(info.directory_path)),
99-
self.fileio.file_name.set(info.filename),
110+
self.fileio.file_path.set(dir_path_as_str),
111+
self.fileio.file_name.set(path_info.filename),
100112
self.fileio.file_write_mode.set(ADFileWriteMode.STREAM),
101113
# For non-HDF file writers, use AD file templating mechanism
102114
# for generating multi-image datasets
@@ -108,7 +120,7 @@ async def begin_capture(self, name: str) -> None:
108120
)
109121

110122
if not await self.fileio.file_path_exists.get_value():
111-
msg = f"File path {info.directory_path} for file plugin does not exist"
123+
msg = f"Path {dir_path_as_str} doesn't exist or not writable!"
112124
raise FileNotFoundError(msg)
113125

114126
# Overwrite num_capture to go forever
@@ -127,7 +139,9 @@ async def open(
127139
frame_shape = await self._dataset_describer.shape()
128140
dtype_numpy = await self._dataset_describer.np_datatype()
129141

130-
await self.begin_capture(name)
142+
self._path_info = self._path_provider(device_name=name)
143+
144+
await self._begin_capture(name)
131145

132146
describe = {
133147
name: DataKey(
@@ -154,30 +168,22 @@ async def get_indices_written(self) -> int:
154168
async def collect_stream_docs(
155169
self, name: str, indices_written: int
156170
) -> AsyncIterator[StreamAsset]:
171+
path_info = error_if_none(
172+
self._path_info, "Writer must be opened before collecting stream docs!"
173+
)
174+
157175
if indices_written:
158176
if not self._emitted_resource:
159-
file_path = Path(await self.fileio.file_path.get_value())
160177
file_name = await self.fileio.file_name.get_value()
161178
file_template = file_name + "_{:06d}" + self._file_extension
162179

163180
frame_shape = await self._dataset_describer.shape()
164181

165-
uri = urlunparse(
166-
(
167-
"file",
168-
"localhost",
169-
str(file_path.absolute()) + "/",
170-
"",
171-
"",
172-
None,
173-
)
174-
)
175-
176182
bundler_composer = ComposeStreamResource()
177183

178184
self._emitted_resource = bundler_composer(
179185
mimetype=self._mimetype,
180-
uri=uri,
186+
uri=str(path_info.directory_uri),
181187
# TODO no reference to detector's name
182188
data_key=name,
183189
parameters={

src/ophyd_async/epics/adcore/_hdf_writer.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import asyncio
22
from collections.abc import AsyncIterator
3-
from pathlib import Path
43
from typing import TypeGuard
54
from xml.etree import ElementTree as ET
65

@@ -65,8 +64,10 @@ async def open(
6564
self.fileio.xml_file_name.set(""),
6665
)
6766

67+
self._path_info = self._path_provider(device_name=name)
68+
6869
# Set common AD file plugin params, begin capturing
69-
await self.begin_capture(name)
70+
await self._begin_capture(name)
7071

7172
detector_shape = await self._dataset_describer.shape()
7273
np_dtype = await self._dataset_describer.np_datatype()
@@ -100,7 +101,7 @@ async def open(
100101

101102
self._composer = HDFDocumentComposer(
102103
# See https://github.com/bluesky/ophyd-async/issues/122
103-
Path(await self.fileio.full_file_name.get_value()),
104+
f"{self._path_info.directory_uri}{self._path_info.filename}{self._file_extension}",
104105
self._datasets,
105106
)
106107

src/ophyd_async/fastcs/panda/_writer.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import asyncio
22
from collections.abc import AsyncGenerator, AsyncIterator
3-
from pathlib import Path
43

54
from bluesky.protocols import StreamAsset
65
from event_model import DataKey
@@ -67,8 +66,7 @@ async def open(self, name: str, exposures_per_event: int = 1) -> dict[str, DataK
6766
describe = await self._describe(name)
6867

6968
self._composer = HDFDocumentComposer(
70-
Path(await self.panda_data_block.hdf_directory.get_value())
71-
/ Path(await self.panda_data_block.hdf_file_name.get_value()),
69+
f"{info.directory_uri}{info.filename}.h5",
7270
self._datasets,
7371
)
7472

src/ophyd_async/sim/__main__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Used for tutorial `Using Devices`."""
22

33
# Import bluesky and ophyd
4+
from pathlib import PurePath
45
from tempfile import mkdtemp
56

67
import bluesky.plan_stubs as bps # noqa: F401
@@ -27,7 +28,7 @@
2728

2829
# Make a path provider that makes UUID filenames within a static
2930
# temporary directory
30-
path_provider = StaticPathProvider(UUIDFilenameProvider(), mkdtemp())
31+
path_provider = StaticPathProvider(UUIDFilenameProvider(), PurePath(mkdtemp()))
3132

3233
# All Devices created within this block will be
3334
# connected and named at the end of the with block

0 commit comments

Comments
 (0)