diff --git a/src/ibex_bluesky_core/callbacks/_file_logger.py b/src/ibex_bluesky_core/callbacks/_file_logger.py index 22159010..6f028e7e 100644 --- a/src/ibex_bluesky_core/callbacks/_file_logger.py +++ b/src/ibex_bluesky_core/callbacks/_file_logger.py @@ -4,6 +4,7 @@ import logging import os from pathlib import Path +from stat import S_IRGRP, S_IROTH, S_IRUSR from bluesky.callbacks import CallbackBase from event_model.documents.event import Event @@ -38,7 +39,9 @@ class HumanReadableFileCallback(CallbackBase): """Outputs bluesky runs to human-readable output files in the specified directory path.""" - def __init__(self, fields: list[str], *, output_dir: Path | None, postfix: str = "") -> None: + def __init__( + self, fields: list[str], *, output_dir: Path | None = None, postfix: str = "" + ) -> None: """Output human-readable output files of bluesky runs. If fields are given, just output those, otherwise output all hinted signals. @@ -67,6 +70,7 @@ def start(self, doc: RunStart) -> None: self.current_start_document = doc[UID] rb_num = _get_rb_num(doc) + rb_num_str = rb_num if rb_num == "Unknown RB" else f"RB{rb_num}" # motors is a tuple, we need to convert to a list to join the two below motors = list(doc.get(MOTORS, [])) @@ -75,7 +79,8 @@ def start(self, doc: RunStart) -> None: self.filename = ( self.output_dir - / f"{rb_num}" + / rb_num_str + / "bluesky_scans" / f"{get_instrument()}{'_' + '_'.join(motors) if motors else ''}_" f"{formatted_time}Z{self.postfix}.txt" ) @@ -150,4 +155,6 @@ def stop(self, doc: RunStop) -> RunStop | None: """Clear descriptors.""" logger.info("Stopping run, clearing descriptors, filename=%s", self.filename) self.descriptors.clear() + if self.filename is not None: + os.chmod(self.filename, S_IRUSR | S_IRGRP | S_IROTH) return super().stop(doc) diff --git a/src/ibex_bluesky_core/callbacks/_fitting.py b/src/ibex_bluesky_core/callbacks/_fitting.py index b4f69d30..5846f66e 100644 --- a/src/ibex_bluesky_core/callbacks/_fitting.py +++ b/src/ibex_bluesky_core/callbacks/_fitting.py @@ -6,6 +6,7 @@ import warnings from itertools import zip_longest from pathlib import Path +from stat import S_IRGRP, S_IROTH, S_IRUSR import lmfit import numpy as np @@ -172,8 +173,9 @@ def start(self, doc: RunStart) -> None: file = f"{get_instrument()}_{self.x}_{self.y}_{title_format_datetime}Z{self.postfix}.txt" rb_num = _get_rb_num(doc) + rb_num_str = rb_num if rb_num == "Unknown RB" else f"RB{rb_num}" - self.filename = self.output_dir / f"{rb_num}" / file + self.filename = self.output_dir / f"{rb_num_str}" / "bluesky_scans" / file def event(self, doc: Event) -> Event: """Start collecting, y, x, and yerr data. @@ -237,6 +239,7 @@ def stop(self, doc: RunStop) -> None: self.write_fields_table_uncertainty() logger.info("Fitting information successfully written to: %s", self.filename.resolve()) + os.chmod(self.filename, S_IRUSR | S_IRGRP | S_IROTH) def write_fields_table(self) -> None: """Write collected run info to the fitting file.""" diff --git a/src/ibex_bluesky_core/callbacks/_plotting.py b/src/ibex_bluesky_core/callbacks/_plotting.py index ddc1f3d4..e816e7df 100644 --- a/src/ibex_bluesky_core/callbacks/_plotting.py +++ b/src/ibex_bluesky_core/callbacks/_plotting.py @@ -4,6 +4,7 @@ import os import threading from pathlib import Path +from stat import S_IRGRP, S_IROTH, S_IRUSR from typing import Any import matplotlib @@ -227,9 +228,12 @@ def __init__( self.filename = None def start(self, doc: RunStart) -> None: + rb_num = _get_rb_num(doc) + rb_num_str = rb_num if rb_num == "Unknown RB" else f"RB{rb_num}" self.filename = ( self.output_dir - / f"{_get_rb_num(doc)}" + / f"{rb_num_str}" + / "bluesky_scans" / f"{get_instrument()}_{self.x}_{self.y}_{format_time(doc)}Z{self.postfix}.png" ) @@ -245,3 +249,4 @@ def stop(self, doc: RunStop) -> None: self.filename.parent.mkdir(parents=True, exist_ok=True) self.ax.figure.savefig(self.filename, format="png") # pyright: ignore [reportAttributeAccessIssue] + os.chmod(self.filename, S_IRUSR | S_IRGRP | S_IROTH) diff --git a/src/ibex_bluesky_core/callbacks/_utils.py b/src/ibex_bluesky_core/callbacks/_utils.py index a2ebbceb..822953f3 100644 --- a/src/ibex_bluesky_core/callbacks/_utils.py +++ b/src/ibex_bluesky_core/callbacks/_utils.py @@ -33,11 +33,7 @@ def get_instrument() -> str: def get_default_output_path() -> Path: output_dir_env = os.environ.get(OUTPUT_DIR_ENV_VAR) - return ( - Path("//isis.cclrc.ac.uk/inst$") / node() / "user" / "bluesky_scans" - if output_dir_env is None - else Path(output_dir_env) - ) + return Path("C:/Data") if output_dir_env is None else Path(output_dir_env) def format_time(doc: Event | RunStart | RunStop) -> str: diff --git a/tests/callbacks/fitting/test_fit_logging_callback.py b/tests/callbacks/fitting/test_fit_logging_callback.py index 4baa621c..dc05a9e8 100644 --- a/tests/callbacks/fitting/test_fit_logging_callback.py +++ b/tests/callbacks/fitting/test_fit_logging_callback.py @@ -2,6 +2,7 @@ import os from pathlib import Path from platform import node +from stat import S_IRGRP, S_IROTH, S_IRUSR from unittest.mock import MagicMock, mock_open, patch import pytest @@ -30,6 +31,7 @@ def test_after_fitting_callback_writes_to_file_successfully_no_y_uncertainty( with ( patch("ibex_bluesky_core.callbacks._fitting.open", m), patch("ibex_bluesky_core.callbacks._fitting.os.makedirs"), + patch("os.chmod"), ): with patch("time.time", MagicMock(return_value=time)): RE( @@ -39,7 +41,10 @@ def test_after_fitting_callback_writes_to_file_successfully_no_y_uncertainty( ) assert m.call_args_list[0].args == ( - filepath / "0" / f"{node()}_motor_invariant_2024-10-04_13-43-43Z{postfix}.txt", + ( + filepath / "RB0" / "bluesky_scans" / f"{node()}_motor_invariant_2024-10-04_13-43-43" + f"Z{postfix}.txt" + ), "w", ) # type: ignore @@ -68,6 +73,7 @@ def test_fitting_callback_handles_no_rb_number_save( with ( patch("ibex_bluesky_core.callbacks._fitting.open", m), patch("ibex_bluesky_core.callbacks._fitting.os.makedirs"), + patch("os.chmod"), ): with patch("time.time", MagicMock(return_value=time)): RE( @@ -76,7 +82,12 @@ def test_fitting_callback_handles_no_rb_number_save( ) assert m.call_args_list[0].args == ( - filepath / "Unknown RB" / f"{node()}_motor_invariant_2024-10-04_13-43-43Z{postfix}.txt", + ( + filepath + / "Unknown RB" + / "bluesky_scans" + / f"{node()}_motor_invariant_2024-10-04_13-43-43Z{postfix}.txt" + ), "w", ) # type: ignore @@ -100,6 +111,7 @@ def test_after_fitting_callback_writes_to_file_successfully_with_y_uncertainty( with ( patch("ibex_bluesky_core.callbacks._fitting.open", m), patch("ibex_bluesky_core.callbacks._fitting.os.makedirs"), + patch("os.chmod"), ): with patch("time.time", MagicMock(return_value=time)): RE( @@ -109,7 +121,10 @@ def test_after_fitting_callback_writes_to_file_successfully_with_y_uncertainty( ) assert m.call_args_list[0].args == ( - filepath / "0" / f"{node()}_motor_invariant_2024-10-04_13-43-43Z{postfix}.txt", + ( + filepath / "RB0" / "bluesky_scans" / f"{node()}_motor_invariant_2024-10-04_13-43-43" + f"Z{postfix}.txt" + ), "w", ) # type: ignore @@ -203,3 +218,35 @@ def test_error_thrown_if_no_y_err_data_in_event(RE: run_engine.RunEngine): } } ) + + +def test_file_set_readonly_after_written( + RE: run_engine.RunEngine, +): + invariant = soft_signal_rw(float, 0.5, name="invariant") + mot = soft_signal_rw(float, name="motor") + + filepath = Path("C:\\") / "instrument" / "var" / "logs" + postfix = "fit1" + m = mock_open() + + lf = LiveFit(Linear.fit(), y="invariant", x="motor", update_every=50) + lfl = LiveFitLogger(lf, y="invariant", x="motor", postfix=postfix, output_dir=filepath) + with ( + patch("ibex_bluesky_core.callbacks._fitting.open", m), + patch("ibex_bluesky_core.callbacks._fitting.os.makedirs"), + patch("os.chmod") as mock_chmod, + ): + with patch("time.time", MagicMock(return_value=time)): + RE( + scan([invariant], mot, -1, 1, 3), + [lf, lfl], # pyright: ignore until https://github.com/bluesky/bluesky/issues/1938 + ) + + fit_filepath = ( + filepath + / "Unknown RB" + / "bluesky_scans" + / f"{node()}_motor_invariant_2024-10-04_13-43-43Z{postfix}.txt" + ) + mock_chmod.assert_called_with(fit_filepath, S_IRUSR | S_IRGRP | S_IROTH) diff --git a/tests/callbacks/test_plotting_callback.py b/tests/callbacks/test_plotting_callback.py index ae579871..24d277c1 100644 --- a/tests/callbacks/test_plotting_callback.py +++ b/tests/callbacks/test_plotting_callback.py @@ -1,3 +1,4 @@ +from stat import S_IRGRP, S_IROTH, S_IRUSR from typing import Any from unittest.mock import MagicMock, patch @@ -82,32 +83,58 @@ def test_errorbars_created_if_yerr_is_given(): def test_png_saved_on_run_stop(): - ax = MagicMock(spec=Axes) - ax.figure = MagicMock(spec=Figure) - s = PlotPNGSaver(x="x", y="y", ax=ax, postfix="123", output_dir="") - - s.start( - { - "uid": "0", - RB: 1234, # pyright: ignore reportArgumentType - "time": 123456789, - } - ) - - s.stop( - { - "time": 234567891, - "uid": "2", - "exit_status": "success", - "run_start": "", - } - ) + with patch("os.chmod"): + ax = MagicMock(spec=Axes) + ax.figure = MagicMock(spec=Figure) + s = PlotPNGSaver(x="x", y="y", ax=ax, postfix="123", output_dir="") + + s.start( + { + "uid": "0", + RB: 1234, # pyright: ignore reportArgumentType + "time": 123456789, + } + ) + + s.stop( + { + "time": 234567891, + "uid": "2", + "exit_status": "success", + "run_start": "", + } + ) assert ax.figure.savefig.call_count == 1 assert ax.figure.savefig.call_args.kwargs["format"] == "png" assert "x_y_1973-11-29_21-33-09Z123.png" in ax.figure.savefig.call_args.args[0].name +def test_file_readonly_on_stop(): + with patch("os.chmod") as mock_chmod: + ax = MagicMock(spec=Axes) + ax.figure = MagicMock(spec=Figure) + s = PlotPNGSaver(x="x", y="y", ax=ax, postfix="123", output_dir="") + + s.start( + { + "uid": "0", + RB: 1234, # pyright: ignore reportArgumentType + "time": 123456789, + } + ) + directory = s.filename + s.stop( + { + "time": 234567891, + "uid": "2", + "exit_status": "success", + "run_start": "", + } + ) + mock_chmod.assert_called_with(directory, S_IRUSR | S_IRGRP | S_IROTH) + + def test_errorbars_not_created_if_no_yerr(): _, ax = plt.subplots() ax.errorbar = MagicMock() diff --git a/tests/callbacks/test_utils.py b/tests/callbacks/test_utils.py index 07a20fd9..36453700 100644 --- a/tests/callbacks/test_utils.py +++ b/tests/callbacks/test_utils.py @@ -14,4 +14,4 @@ def test_default_output_location_with_env_var(): @pytest.mark.skipif(os.name != "nt", reason="Windows only") def test_default_output_location_without_env_var(): with patch("ibex_bluesky_core.callbacks._utils.os.environ.get", return_value=None): - assert str(get_default_output_path()).startswith(r"\\isis.cclrc.ac.uk") + assert str(get_default_output_path()) == r"C:\Data" diff --git a/tests/callbacks/test_write_log_callback.py b/tests/callbacks/test_write_log_callback.py index 935e290b..35a8c23e 100644 --- a/tests/callbacks/test_write_log_callback.py +++ b/tests/callbacks/test_write_log_callback.py @@ -3,6 +3,7 @@ from pathlib import Path from platform import node +from stat import S_IRGRP, S_IROTH, S_IRUSR from unittest.mock import call, mock_open, patch import pytest @@ -32,7 +33,8 @@ def test_header_data_all_available_on_start(cb): cb.start(run_start) result = ( save_path - / f"{run_start.get('rb_number', None)}" + / f"RB{run_start.get('rb_number', None)}" + / "bluesky_scans" / f"{node()}_block_2024-10-04_13-43-43Z.txt" ) @@ -54,11 +56,13 @@ def test_no_rb_number_folder(cb): patch("ibex_bluesky_core.callbacks._file_logger.os.makedirs") as mock_mkdir, ): cb.start(run_start) - result = save_path / "Unknown RB" / f"{node()}_block_2024-10-04_13-43-43Z.txt" + result = ( + save_path / "Unknown RB" / "bluesky_scans" / f"{node()}_block_2024-10-04_13-43-43Z.txt" + ) assert mock_mkdir.called mock_file.assert_called_with(result, "a", newline="\n", encoding="utf-8") - # time should have been renamed to start_time and converted to human readable + # time should have been renamed to start_time and converted to human-readable writelines_call_args = mock_file().writelines.call_args[0][0] assert "start_time: 2024-10-04_13-43-43\n" in writelines_call_args assert f"uid: {uid}\n" in writelines_call_args @@ -75,7 +79,7 @@ def test_no_motors_doesnt_append_to_filename(cb): patch("ibex_bluesky_core.callbacks._file_logger.os.makedirs") as mock_mkdir, ): cb.start(run_start) - result = save_path / "Unknown RB" / f"{node()}_2024-10-04_13-43-43Z.txt" + result = save_path / "Unknown RB" / "bluesky_scans" / f"{node()}_2024-10-04_13-43-43Z.txt" assert mock_mkdir.called mock_file.assert_called_with(result, "a", newline="\n", encoding="utf-8") @@ -202,7 +206,37 @@ def test_event_called_before_filename_specified_does_nothing(): def test_stop_clears_descriptors(cb): cb.descriptors["test"] = EventDescriptor(uid="test", run_start="", time=0.1, data_keys={}) - - cb.stop(RunStop(uid="test", run_start="", time=0.1, exit_status="success")) + with patch("os.chmod"): + cb.stop(RunStop(uid="test", run_start="", time=0.1, exit_status="success")) assert not cb.descriptors + + +def test_file_set_readonly_when_finished(cb): + time = 1728049423.5860472 + start_uid = "test123start" + stop_uid = "test123stop" + scan_id = 1234 + run_start = RunStart( + time=time, + uid=start_uid, + scan_id=scan_id, + rb_number="0", + detectors=["dae"], + motors=("block",), + ) + run_stop = RunStop(time=time, run_start=start_uid, uid=stop_uid, exit_status="success") + with ( + patch("ibex_bluesky_core.callbacks._file_logger.open", mock_open()), + patch("ibex_bluesky_core.callbacks._file_logger.os.makedirs"), + patch("ibex_bluesky_core.callbacks._file_logger.os.chmod") as mock_chmod, + ): + cb.start(run_start) + result = ( + save_path + / f"RB{run_start.get('rb_number', None)}" + / "bluesky_scans" + / f"{node()}_block_2024-10-04_13-43-43Z.txt" + ) + cb.stop(run_stop) + mock_chmod.assert_called_with(result, S_IRUSR | S_IRGRP | S_IROTH)