diff --git a/pysus/native_dir_picker.py b/pysus/native_dir_picker.py new file mode 100644 index 00000000..b457c8a4 --- /dev/null +++ b/pysus/native_dir_picker.py @@ -0,0 +1,86 @@ +"""Cross-platform native directory picker.""" + +import os +import platform +import subprocess + +_WINDOWS_SCRIPT = """ +Add-Type -AssemblyName System.Windows.Forms +$f = New-Object System.Windows.Forms.FolderBrowserDialog +$f.Description = $env:PYSUS_DIALOG_TITLE +$f.SelectedPath = $env:PYSUS_DIALOG_INITIALDIR +$f.ShowDialog() | Out-Null +$f.SelectedPath +""" + +_MACOS_SCRIPT = """ +set dialogTitle to system attribute "PYSUS_DIALOG_TITLE" +set initialDirectory to system attribute "PYSUS_DIALOG_INITIALDIR" +tell application "System Events" + activate + set f to choose folder with prompt dialogTitle ¬ + default location POSIX file initialDirectory + POSIX path of f +end tell +""" + + +def _dialog_environment(title: str, initialdir: str) -> dict[str, str]: + env = os.environ.copy() + env["PYSUS_DIALOG_TITLE"] = title + env["PYSUS_DIALOG_INITIALDIR"] = initialdir + return env + + +def native_dir_picker(title: str, initialdir: str) -> str: + """Open a native directory picker and return the selected path. + + Values are passed as command arguments or environment variables instead + of being interpolated into scripts executed by platform interpreters. + """ + system = platform.system() + + if system == "Linux": + for cmd in ( + [ + "zenity", + "--file-selection", + "--directory", + f"--filename={initialdir}/", + f"--title={title}", + ], + ["kdialog", "--getexistingdirectory", initialdir, "--title", title], + ): + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=30 + ) + return result.stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired): + continue + + elif system == "Windows": + result = subprocess.run( + [ + "powershell", + "-NoProfile", + "-NonInteractive", + "-Command", + _WINDOWS_SCRIPT, + ], + capture_output=True, + text=True, + env=_dialog_environment(title, initialdir), + ) + return result.stdout.strip() + + elif system == "Darwin": + result = subprocess.run( + ["osascript", "-e", _MACOS_SCRIPT], + capture_output=True, + text=True, + env=_dialog_environment(title, initialdir), + ) + return result.stdout.strip() + + return "" diff --git a/pysus/tests/web/test_native_dir_picker.py b/pysus/tests/web/test_native_dir_picker.py new file mode 100644 index 00000000..7b342120 --- /dev/null +++ b/pysus/tests/web/test_native_dir_picker.py @@ -0,0 +1,96 @@ +import subprocess +from unittest.mock import Mock, patch + +from pysus.native_dir_picker import native_dir_picker + +_TITLE = "Selecionar exportacao da unidade 'APS Central'" +_INITIALDIR = 'C:\\Dados APS\\Unidade "Central"' + + +@patch("pysus.native_dir_picker.subprocess.run") +@patch("pysus.native_dir_picker.platform.system", return_value="Windows") +def test_windows_values_are_passed_through_environment(_, run): + run.return_value = Mock(stdout="C:\\selected\n") + + assert native_dir_picker(_TITLE, _INITIALDIR) == "C:\\selected" + + args = run.call_args.args[0] + kwargs = run.call_args.kwargs + assert _TITLE not in args[-1] + assert _INITIALDIR not in args[-1] + assert kwargs["env"]["PYSUS_DIALOG_TITLE"] == _TITLE + assert kwargs["env"]["PYSUS_DIALOG_INITIALDIR"] == _INITIALDIR + + +@patch("pysus.native_dir_picker.subprocess.run") +@patch("pysus.native_dir_picker.platform.system", return_value="Darwin") +def test_macos_values_are_passed_through_environment(_, run): + run.return_value = Mock(stdout="/tmp/selected\n") + + assert native_dir_picker(_TITLE, _INITIALDIR) == "/tmp/selected" + + args = run.call_args.args[0] + kwargs = run.call_args.kwargs + assert _TITLE not in args[-1] + assert _INITIALDIR not in args[-1] + assert kwargs["env"]["PYSUS_DIALOG_TITLE"] == _TITLE + assert kwargs["env"]["PYSUS_DIALOG_INITIALDIR"] == _INITIALDIR + + +@patch("pysus.native_dir_picker.subprocess.run") +@patch("pysus.native_dir_picker.platform.system", return_value="Linux") +def test_linux_values_are_passed_as_arguments(_, run): + run.return_value = Mock(stdout="/tmp/selected\n") + + assert native_dir_picker(_TITLE, _INITIALDIR) == "/tmp/selected" + + args = run.call_args.args[0] + assert args[-1] == f"--title={_TITLE}" + assert args[-2] == f"--filename={_INITIALDIR}/" + + +@patch("pysus.native_dir_picker.subprocess.run") +@patch("pysus.native_dir_picker.platform.system", return_value="Linux") +def test_linux_falls_back_to_kdialog_when_zenity_is_unavailable(_, run): + run.side_effect = [ + FileNotFoundError, + Mock(stdout="/tmp/selected-by-kdialog\n"), + ] + + assert native_dir_picker(_TITLE, _INITIALDIR) == "/tmp/selected-by-kdialog" + + assert run.call_count == 2 + assert run.call_args_list[1].args[0] == [ + "kdialog", + "--getexistingdirectory", + _INITIALDIR, + "--title", + _TITLE, + ] + + +@patch("pysus.native_dir_picker.subprocess.run") +@patch("pysus.native_dir_picker.platform.system", return_value="Linux") +def test_linux_returns_empty_when_all_pickers_fail(_, run): + run.side_effect = [ + FileNotFoundError, + subprocess.TimeoutExpired("kdialog", 30), + ] + + assert native_dir_picker(_TITLE, _INITIALDIR) == "" + assert run.call_count == 2 + + +@patch("pysus.native_dir_picker.subprocess.run") +@patch("pysus.native_dir_picker.platform.system", return_value="Windows") +def test_cancelled_picker_returns_empty(_, run): + run.return_value = Mock(stdout="") + + assert native_dir_picker(_TITLE, _INITIALDIR) == "" + + +@patch("pysus.native_dir_picker.subprocess.run") +@patch("pysus.native_dir_picker.platform.system", return_value="FreeBSD") +def test_unsupported_platform_returns_empty_without_running_command(_, run): + assert native_dir_picker(_TITLE, _INITIALDIR) == "" + run.assert_not_called() diff --git a/pysus/web/pages/1_client.py b/pysus/web/pages/1_client.py index fd5cfdb5..87a0dc33 100644 --- a/pysus/web/pages/1_client.py +++ b/pysus/web/pages/1_client.py @@ -8,6 +8,7 @@ from pysus import CACHEPATH from pysus.api.client import PySUS from pysus.api.models import BaseRemoteFile +from pysus.native_dir_picker import native_dir_picker from pysus.web.translations import t STATES = [ @@ -699,70 +700,6 @@ def _size_column_config() -> dict[str, Any]: } -def _native_dir_picker(title: str, initialdir: str) -> str: - """Open a native directory picker dialog and return the selected path.""" - import platform - import subprocess - - system = platform.system() - - if system == "Linux": - for cmd in ( - [ - "zenity", - "--file-selection", - "--directory", - f"--filename={initialdir}/", - f"--title={title}", - ], - ["kdialog", "--getexistingdirectory", initialdir, "--title", title], - ): - try: - r = subprocess.run( - cmd, capture_output=True, text=True, timeout=30 - ) - return r.stdout.strip() - except (FileNotFoundError, subprocess.TimeoutExpired): - continue - - elif system == "Windows": - ps = f""" -Add-Type -AssemblyName System.Windows.Forms -$f = New-Object System.Windows.Forms.FolderBrowserDialog -$f.Description = '{title}' -$f.SelectedPath = '{initialdir}' -$f.ShowDialog() | Out-Null -$f.SelectedPath -""" - r = subprocess.run( - ["powershell", "-Command", ps], - capture_output=True, - text=True, - ) - return r.stdout.strip() - - elif system == "Darwin": - prompt_line = ( - 'set f to choose folder with prompt "{}"' - ' default location POSIX file "{}"' - ).format(title, initialdir) - applescript = ( - f'tell application "System Events"\n' - f" activate\n" - f" {prompt_line}\n" - f" POSIX path of f\n" - f"end tell" - ) - r = subprocess.run( - ["osascript", "-e", applescript], - capture_output=True, - text=True, - ) - return r.stdout.strip() - - return "" - - def _show_results(pysus: PySUS, client: str) -> None: query_key = f"_query_results_{client}" queue_key = f"_download_queue_{client}" @@ -879,7 +816,7 @@ def _show_results(pysus: PySUS, client: str) -> None: ) with col_btn: if st.button(t("browse", _lang()), width="stretch"): - folder = _native_dir_picker( + folder = native_dir_picker( title=t("browse_dir_title", _lang()), initialdir=st.session_state[dir_key], )