diff --git a/CHANGELOG.md b/CHANGELOG.md index 08d50906..522b408d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -... +### Added +- `seabreeze_os_setup` preview the udev rules on linux before installing them + +### Changed +- `seabreeze_os_setup` install the udev rules with mode `644` on linux ## [2.10.1] - 2025-01-29 ### Fixed diff --git a/os_support/readme.md b/os_support/readme.md index f951a288..35e75057 100644 --- a/os_support/readme.md +++ b/os_support/readme.md @@ -20,8 +20,10 @@ That depends on your operating system. Currently only Linux and Windows require additional setup. OSX works ootb and is a no-op when running `seabreeze_os_setup` -**On Linux** it downloads and copies the `10-oceanoptics.rules` file to -`/etc/udev/rules.d/` and runs `sudo udevadm control --reload-rules`. +**On Linux** it downloads the `10-oceanoptics.rules` file, prints its contents +for review, and, after you confirm, copies it to `/etc/udev/rules.d/` with mode +`644` and runs `sudo udevadm control --reload-rules`. If you decline, the rules +are not installed and the commands for installing them manually are printed. **On Windows** it downloads and extracts the `windows-driver-files.zip` archive and runs `pnputil -i -a *.inf` inside the extracted folder in an admin shell. diff --git a/src/seabreeze/os_setup.py b/src/seabreeze/os_setup.py index 17f2f81c..56158716 100644 --- a/src/seabreeze/os_setup.py +++ b/src/seabreeze/os_setup.py @@ -23,6 +23,7 @@ "https://raw.githubusercontent.com/ap--/python-seabreeze/master/os_support" ) _UDEV_RULES_PATH = "/etc/udev/rules.d/10-oceanoptics.rules" +_UDEV_RULES_MODE = "644" _DRIVERS_ZIP_FN = "windows-driver-files.zip" _log = logging.getLogger(__name__) @@ -35,6 +36,12 @@ def _diff_files(file1, file2): return err.output.decode("utf8") +def _preview_file(filename): + """return an indented preview of a file's contents""" + with open(filename, encoding="utf8") as f: + return indent(f.read().rstrip(), " ") + + def _request_confirmation(question): """require user input to continue""" while True: @@ -100,12 +107,31 @@ def linux_install_udev_rules(): ) sys.exit(1) + # show the rules to the user before installing them + _log.info(f"The following udev rules will be installed as {_UDEV_RULES_PATH}:") + _log.info(_preview_file(udev_fn)) + if not _request_confirmation("Install udev rules?"): + _log.info( + dedent( + f"""\ + To install the rules manually, copy the rules shown above to + {_UDEV_RULES_PATH} and run: + + sudo chmod {_UDEV_RULES_MODE} {_UDEV_RULES_PATH} + sudo udevadm control --reload-rules""" + ) + ) sys.exit(0) - # cp rules and execute + # install rules and execute _log.info(f"Copying udev rules to {_UDEV_RULES_PATH}") - subprocess.call(["sudo", "cp", udev_fn, _UDEV_RULES_PATH]) + return_code = subprocess.call( + ["sudo", "install", "-m", _UDEV_RULES_MODE, udev_fn, _UDEV_RULES_PATH] + ) + if return_code != 0: + _log.error(f"Copying udev rules failed with return code {return_code}") + sys.exit(1) _log.info("Calling udevadm control --reload-rules") subprocess.call(["sudo", "udevadm", "control", "--reload-rules"]) _log.info("Success") diff --git a/tests/test_os_setup.py b/tests/test_os_setup.py new file mode 100644 index 00000000..ea209bd3 --- /dev/null +++ b/tests/test_os_setup.py @@ -0,0 +1,86 @@ +"""tests for the `seabreeze_os_setup` command line tool""" + +import logging +import sys +from unittest import mock + +import pytest + +from seabreeze import os_setup + + +@pytest.fixture +def udev_rules_file(tmp_path): + """a local rules file provided to the setup script""" + rules_file = tmp_path / "10-oceanoptics.rules" + rules_file.write_text("# oceanoptics test rules\n") + yield str(rules_file) + + +@pytest.fixture +def udev_rules_path(tmp_path, monkeypatch): + """a not yet existing udev rules install location""" + rules_path = str(tmp_path / "rules.d" / "10-oceanoptics.rules") + monkeypatch.setattr(os_setup, "_UDEV_RULES_PATH", rules_path) + yield rules_path + + +@pytest.fixture +def subprocess_call(): + with mock.patch.object(os_setup.subprocess, "call", return_value=0) as call: + yield call + + +def _run_linux_install_udev_rules(rules_file, monkeypatch, confirm): + monkeypatch.setattr(sys, "argv", ["seabreeze_os_setup", rules_file]) + monkeypatch.setattr(os_setup, "_request_confirmation", lambda question: confirm) + with pytest.raises(SystemExit) as exc_info: + os_setup.linux_install_udev_rules() + return exc_info.value.code + + +def test_linux_install_udev_rules_previews_rules( + udev_rules_file, udev_rules_path, subprocess_call, monkeypatch, caplog +): + """the rules are printed before they get installed""" + caplog.set_level(logging.INFO) + code = _run_linux_install_udev_rules(udev_rules_file, monkeypatch, confirm=True) + + assert code == 0 + assert "# oceanoptics test rules" in caplog.text + assert udev_rules_path in caplog.text + + +def test_linux_install_udev_rules_installs_with_644( + udev_rules_file, udev_rules_path, subprocess_call, monkeypatch +): + """the rules are installed world readable""" + code = _run_linux_install_udev_rules(udev_rules_file, monkeypatch, confirm=True) + + assert code == 0 + assert ( + mock.call(["sudo", "install", "-m", "644", udev_rules_file, udev_rules_path]) + in subprocess_call.call_args_list + ) + + +def test_linux_install_udev_rules_declined( + udev_rules_file, udev_rules_path, subprocess_call, monkeypatch, caplog +): + """nothing is installed and manual instructions are shown""" + caplog.set_level(logging.INFO) + code = _run_linux_install_udev_rules(udev_rules_file, monkeypatch, confirm=False) + + assert code == 0 + assert subprocess_call.call_args_list == [] + assert f"sudo chmod 644 {udev_rules_path}" in caplog.text + + +def test_linux_install_udev_rules_install_error( + udev_rules_file, udev_rules_path, subprocess_call, monkeypatch +): + """a failing install command is reported as an error""" + subprocess_call.return_value = 1 + code = _run_linux_install_udev_rules(udev_rules_file, monkeypatch, confirm=True) + + assert code == 1