diff --git a/src/pylsl/__init__.py b/src/pylsl/__init__.py index 5e016aa..d44c93e 100644 --- a/src/pylsl/__init__.py +++ b/src/pylsl/__init__.py @@ -34,6 +34,8 @@ from .util import library_version as library_version from .util import library_info as library_info from .util import local_clock as local_clock +from .util import set_config_filename as set_config_filename +from .util import set_config_content as set_config_content from .lib import cf_int8 as cf_int8 from .lib import cf_int16 as cf_int16 from .lib import cf_int32 as cf_int32 diff --git a/src/pylsl/lib/__init__.py b/src/pylsl/lib/__init__.py index 1e33a24..7daf872 100644 --- a/src/pylsl/lib/__init__.py +++ b/src/pylsl/lib/__init__.py @@ -314,6 +314,15 @@ def find_liblsl_libraries(verbose=False): lib.lsl_create_continuous_resolver_byprop.restype = ctypes.c_void_p except Exception: print("pylsl: ContinuousResolver not (fully) available in your liblsl version.") +# noinspection PyBroadException +try: + lib.lsl_set_config_filename.argtypes = [ctypes.c_char_p] + lib.lsl_set_config_filename.restype = None + lib.lsl_set_config_content.argtypes = [ctypes.c_char_p] + lib.lsl_set_config_content.restype = None +except Exception: + # Available in liblsl >= 1.17.7; older versions don't expose these. + pass # int64 support on windows and 32bit OSes isn't there yet diff --git a/src/pylsl/util.py b/src/pylsl/util.py index 1a68433..54a0305 100644 --- a/src/pylsl/util.py +++ b/src/pylsl/util.py @@ -60,6 +60,36 @@ def library_info(): return lib.lsl_library_info().decode("utf-8") +def set_config_filename(filename: str) -> None: + """Set the path of the configuration file to be used by liblsl. + + Must be called before any other LSL function; the configuration is loaded + lazily on first use, so calling this afterwards has no effect. + """ + if not hasattr(lib, "lsl_set_config_filename"): + raise NotImplementedError( + "lsl_set_config_filename is not available in your liblsl version " + "(requires liblsl >= 1.17.7)." + ) + lib.lsl_set_config_filename(filename.encode("utf-8")) + + +def set_config_content(content: str) -> None: + """Set the configuration content (as an INI string) to be used by liblsl. + + Must be called before any other LSL function; the configuration is loaded + lazily on first use, so calling this afterwards has no effect. When set, + this content takes precedence over any configuration file. The content is + discarded after liblsl has initialized. + """ + if not hasattr(lib, "lsl_set_config_content"): + raise NotImplementedError( + "lsl_set_config_content is not available in your liblsl version " + "(requires liblsl >= 1.17.7)." + ) + lib.lsl_set_config_content(content.encode("utf-8")) + + def local_clock(): """Obtain a local system time stamp in seconds. diff --git a/test/test_set_config.py b/test/test_set_config.py new file mode 100644 index 0000000..4175a14 --- /dev/null +++ b/test/test_set_config.py @@ -0,0 +1,72 @@ +"""Tests for set_config_filename and set_config_content. + +Both functions must run before liblsl's config is loaded (on first use), so +the effect-verifying tests run in a fresh Python subprocess. In-process smoke +tests only verify that the call path works — they may be no-ops if liblsl has +already initialized in the current process. +""" + +import subprocess +import sys +import textwrap + +import pytest + +import pylsl + + +def test_set_config_content_accepts_string(): + # Safe to call even after init — content is simply ignored once liblsl has loaded. + pylsl.set_config_content("[lab]\nSessionID = ignored_after_init\n") + + +def test_set_config_filename_accepts_string(): + pylsl.set_config_filename("/nonexistent/path/to/lsl.cfg") + + +def test_set_config_content_rejects_non_string(): + with pytest.raises(AttributeError): + pylsl.set_config_content(b"not a str") + + +def test_set_config_filename_rejects_non_string(): + with pytest.raises(AttributeError): + pylsl.set_config_filename(12345) + + +def _run_in_subprocess(script: str) -> str: + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +def test_set_config_content_applies_session_id(): + """Setting content before any LSL call should update the session id that + outlets inherit from the library config.""" + session_id = "pytest_content_session" + script = textwrap.dedent(f""" + import pylsl + pylsl.set_config_content('[lab]\\nSessionID = {session_id}\\n') + info = pylsl.StreamInfo('T', 'EEG', 1, 100, 'float32', 'src') + outlet = pylsl.StreamOutlet(info) + print(outlet.get_info().session_id()) + """) + assert _run_in_subprocess(script) == session_id + + +def test_set_config_filename_applies_session_id(tmp_path): + session_id = "pytest_filename_session" + cfg = tmp_path / "lsl.cfg" + cfg.write_text(f"[lab]\nSessionID = {session_id}\n") + script = textwrap.dedent(f""" + import pylsl + pylsl.set_config_filename({str(cfg)!r}) + info = pylsl.StreamInfo('T', 'EEG', 1, 100, 'float32', 'src') + outlet = pylsl.StreamOutlet(info) + print(outlet.get_info().session_id()) + """) + assert _run_in_subprocess(script) == session_id