From 1b58d652e589eef400e8039f2dd2be6ac8d92029 Mon Sep 17 00:00:00 2001 From: "Gabriel A. Devenyi" Date: Mon, 10 Aug 2026 13:47:41 -0400 Subject: [PATCH] Open the 2dseq memory map read-only read_array maps a filesystem path with np.memmap and no mode. numpy's default is "r+", so every dataset load asks the operating system for write access to the raw data file. Nothing writes it: the array is copied out of the map on the same line. The consequence is that a dataset on a read-only file or mount cannot be loaded at all -- which is how a shared scanner archive is normally exposed: PermissionError: [Errno 13] Permission denied: '.../2/pdata/1/2dseq' The parameter files are unaffected, since those are opened in text mode, so the failure looks selective: acqp, method, visu_pars and reco all read, and only the binary fails, once per scan. Reproduced by copying a PV6 study, chmod -R a-w, and loading it; on the same file, mode="r+" raises and mode="r" reads all 1048576 values. The archive branch below already reads through path.open("rb") and was never affected. test_dataset_reads_from_a_read_only_source covers it, in test_paths.py because that module's study is synthetic and needs no corpus. It fails with PermissionError without this change. --- brukerapi/paths.py | 7 ++++++- test/test_paths.py | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/brukerapi/paths.py b/brukerapi/paths.py index 1ffb23a..19fa031 100644 --- a/brukerapi/paths.py +++ b/brukerapi/paths.py @@ -71,9 +71,14 @@ def read_array(path, dtype, shape, order="F"): A filesystem path is memory-mapped as before. An archive member cannot be (a memory map cannot address a compressed member), so it degrades to a full read. + + The map is opened read-only. numpy's default mode is "r+", which asks the + operating system for write access and so fails with EACCES on a read-only file + or mount -- the usual way a shared scanner archive is exposed. Nothing here + writes: the array is copied out on the same line. """ if isinstance(path, (str, os.PathLike)): - return np.array(np.memmap(path, dtype=dtype, shape=shape, order=order)[:]) + return np.array(np.memmap(path, dtype=dtype, mode="r", shape=shape, order=order)[:]) with path.open("rb") as binary: buffer = binary.read() return np.frombuffer(buffer, dtype=dtype).reshape(shape, order=order) diff --git a/test/test_paths.py b/test/test_paths.py index febe765..4926c60 100644 --- a/test/test_paths.py +++ b/test/test_paths.py @@ -82,6 +82,28 @@ def test_dataset_reads_from_archive_identically(study_dir, study_zip): assert from_zip.shape_final == from_dir.shape_final +def test_dataset_reads_from_a_read_only_source(study_dir): + """A dataset whose files are not writable must still load. + + Scanner archives are normally exposed read-only, and nothing here writes: the + array is copied out of the map immediately. numpy's default memmap mode is + "r+", which asks the operating system for write access and fails with EACCES + on such a source, so the mode has to be given explicitly. + """ + for path in sorted(study_dir.rglob("*")): + if path.is_file(): + path.chmod(0o444) + + try: + dataset = Dataset(study_dir / "1" / "pdata" / "1" / "2dseq", scale=False) + assert np.array_equal(dataset.data, DATA) + finally: + # restore, so pytest can clean the temporary directory up + for path in sorted(study_dir.rglob("*")): + if path.is_file(): + path.chmod(0o644) + + def test_parameters_resolve_through_relative_paths(study_dir, study_zip): """``../../acqp`` resolves inside an archive, where ``..`` is not collapsed.""" dataset = Dataset(study_zip / "1" / "pdata" / "1" / "2dseq", scale=False,