diff --git a/examples/ui/file_browser.py b/examples/ui/file_browser.py index 23961aec91d..5c1cc3bf580 100644 --- a/examples/ui/file_browser.py +++ b/examples/ui/file_browser.py @@ -24,5 +24,18 @@ def _(file_browser): return +@app.cell +def _(mo): + file_browser_all = mo.ui.file_browser(selection_mode="all") + file_browser_all + return (file_browser_all,) + + +@app.cell +def _(file_browser_all): + file_browser_all.value + return + + if __name__ == "__main__": app.run() diff --git a/marimo/_plugins/ui/_impl/file_browser.py b/marimo/_plugins/ui/_impl/file_browser.py index 5f7686abc67..602de44f07e 100644 --- a/marimo/_plugins/ui/_impl/file_browser.py +++ b/marimo/_plugins/ui/_impl/file_browser.py @@ -14,7 +14,6 @@ from marimo import _loggers from marimo._output.rich_help import mddoc from marimo._plugins.ui._core.ui_element import UIElement -from marimo._plugins.validators import validate_one_of from marimo._runtime.functions import Function from marimo._utils.files import natural_sort from marimo._utils.paths import is_cloudpath, normalize_path @@ -22,6 +21,50 @@ LOGGER = _loggers.marimo_logger() +_VALID_KINDS: Final[frozenset[str]] = frozenset({"file", "directory"}) + + +def _normalize_selection_mode( + value: object, +) -> frozenset[str]: + """Normalize ``selection_mode`` to a frozenset of selectable kinds. + + Accepted inputs: + - ``"file"`` -> ``{"file"}`` + - ``"directory"`` -> ``{"directory"}`` + - ``"all"`` -> ``{"file", "directory"}`` + - list/tuple containing ``"file"`` and/or ``"directory"`` (deduped) + """ + if isinstance(value, str): + if value == "all": + return frozenset(_VALID_KINDS) + if value in _VALID_KINDS: + return frozenset({value}) + raise ValueError( + f"Invalid selection_mode {value!r}. " + f"Expected one of 'file', 'directory', 'all', " + f"or a list of 'file'/'directory'." + ) + + if isinstance(value, (list, tuple)): + if len(value) == 0: + raise ValueError("selection_mode list must not be empty.") + kinds: set[str] = set() + for kind in value: + if not isinstance(kind, str) or kind not in _VALID_KINDS: + raise ValueError( + f"Invalid selection_mode entry {kind!r}. " + f"Each entry must be 'file' or 'directory'." + ) + kinds.add(kind) + return frozenset(kinds) + + raise ValueError( + f"selection_mode must be a string or a list of strings, " + f"got {type(value).__name__}." + ) + + @dataclass class ListDirectoryArgs: path: str @@ -70,6 +113,16 @@ class file_browser( file_browser.name(index=0) ``` + Selecting both files and directories (useful for formats like + deltalake that are stored as directories): + ```python + file_browser = mo.ui.file_browser( + initial_path=Path("path/to/dir"), + selection_mode="all", + # Equivalent: selection_mode=["file", "directory"] + ) + ``` + Connecting to an S3 (or GCS, Azure) bucket: ```python from cloudpathlib import S3Path @@ -114,8 +167,10 @@ class file_browser( filetypes (Sequence[str], optional): The file types to display in each directory; for example, filetypes=[".txt", ".csv"]. If None, all files are displayed. Defaults to None. - selection_mode (Literal["file", "directory"], optional): Either "file" or "directory". Defaults to - "file". + selection_mode (str | Sequence[str], optional): Which kinds of entries + the user can select. Accepts one of "file" (default), "directory", + "all", or a list/tuple containing "file" and/or "directory". + "all" is equivalent to ["file", "directory"]. Defaults to "file". multiple (bool, optional): If True, allow the user to select multiple files. Defaults to True. restrict_navigation (bool, optional): If True, prevent the user from @@ -140,7 +195,8 @@ def __init__( self, initial_path: str | Path = "", filetypes: Sequence[str] | None = None, - selection_mode: Literal["file", "directory"] = "file", + selection_mode: Literal["file", "directory", "all"] + | Sequence[Literal["file", "directory"]] = "file", multiple: bool = True, restrict_navigation: bool = False, *, @@ -150,7 +206,7 @@ def __init__( | None = None, ignore_empty_dirs: bool = False, ) -> None: - validate_one_of(selection_mode, ["file", "directory"]) + self._selection_mode = _normalize_selection_mode(selection_mode) # Save the Path class of the initial path self._path_cls: type[Path] @@ -176,7 +232,6 @@ def __init__( f"Initial path {initial_path} is not a directory." ) - self._selection_mode = selection_mode # Normalize filetypes: ensure lowercase and dot prefix for case-insensitive matching if filetypes: normalized_filetypes = set() @@ -201,13 +256,18 @@ def __init__( self._limit = limit + if self._selection_mode == _VALID_KINDS: + wire_selection_mode = "all" + else: + (wire_selection_mode,) = self._selection_mode + super().__init__( component_name=file_browser._name, initial_value=[], label=label, args={ "initial-path": str(self._initial_path), - "selection-mode": selection_mode, + "selection-mode": wire_selection_mode, "filetypes": filetypes if filetypes is not None else [], "multiple": multiple, "restrict-navigation": restrict_navigation, @@ -316,8 +376,9 @@ def _list_directory( extension = file.suffix is_directory = file.is_dir() # Expensive call for cloud paths - # Skip non-directories if selection mode is directory - if self._selection_mode == "directory" and not is_directory: + # Directories are always shown so the user can navigate into + # them. Files are hidden when files aren't selectable. + if not is_directory and "file" not in self._selection_mode: continue # Skip non-matching file types (case-insensitive) diff --git a/tests/_plugins/ui/_impl/test_file_browser.py b/tests/_plugins/ui/_impl/test_file_browser.py index 8118d1825fd..92491486941 100644 --- a/tests/_plugins/ui/_impl/test_file_browser.py +++ b/tests/_plugins/ui/_impl/test_file_browser.py @@ -11,6 +11,7 @@ FileBrowserFileInfo, ListDirectoryArgs, ListDirectoryResponse, + _normalize_selection_mode, file_browser, ) from marimo._utils.paths import normalize_path @@ -21,7 +22,7 @@ def test_file_browser_init(tmp_path: Path) -> None: fb = file_browser(initial_path=tmp_path) assert isinstance(fb._initial_path, Path) assert str(fb._initial_path) == str(normalize_path(tmp_path)) - assert fb._selection_mode == "file" + assert fb._selection_mode == frozenset({"file"}) assert fb._filetypes == set() assert fb._restrict_navigation is False @@ -35,7 +36,7 @@ def test_file_browser_init(tmp_path: Path) -> None: ) assert fb._initial_path == normalize_path(tmp_path) assert fb._filetypes == set(custom_filetypes) - assert fb._selection_mode == "directory" + assert fb._selection_mode == frozenset({"directory"}) assert fb._restrict_navigation is True @@ -352,8 +353,8 @@ def resolve(self) -> CustomPathWithClient: def test_validation() -> None: with pytest.raises(ValueError) as e: - file_browser(initial_path="invalid", selection_mode="invalid") - assert "Value must be one of" in str(e.value) + file_browser(initial_path="invalid", selection_mode="invalid") # type: ignore[arg-type] + assert "Invalid selection_mode" in str(e.value) def test_limit_arg(tmp_path: Path) -> None: @@ -1090,3 +1091,144 @@ def test_file_browser_relative_path_sent_to_frontend_as_absolute( finally: os.chdir(original_cwd) + + +class TestNormalizeSelectionMode: + def test_string_file(self) -> None: + assert _normalize_selection_mode("file") == frozenset({"file"}) + + def test_string_directory(self) -> None: + assert _normalize_selection_mode("directory") == frozenset( + {"directory"} + ) + + def test_string_all(self) -> None: + assert _normalize_selection_mode("all") == frozenset( + {"file", "directory"} + ) + + def test_list_single_file(self) -> None: + assert _normalize_selection_mode(["file"]) == frozenset({"file"}) + + def test_list_single_directory(self) -> None: + assert _normalize_selection_mode(["directory"]) == frozenset( + {"directory"} + ) + + def test_list_both(self) -> None: + assert _normalize_selection_mode(["file", "directory"]) == frozenset( + {"file", "directory"} + ) + + def test_list_order_independent(self) -> None: + assert _normalize_selection_mode(["directory", "file"]) == frozenset( + {"file", "directory"} + ) + + def test_list_dedup(self) -> None: + assert _normalize_selection_mode(["file", "file"]) == frozenset( + {"file"} + ) + + def test_tuple_accepted(self) -> None: + assert _normalize_selection_mode(("file", "directory")) == frozenset( + {"file", "directory"} + ) + + @pytest.mark.parametrize( + "value", + [ + "both", + "folder", + "", + "FILE", + [], + ["both"], + ["file", "nope"], + ["file", 1], + None, + 123, + ], + ) + def test_rejects_invalid(self, value: Any) -> None: + with pytest.raises(ValueError): + _normalize_selection_mode(value) + + +class TestSelectionModeAll: + def test_init_all_string(self, tmp_path: Path) -> None: + fb = file_browser(initial_path=tmp_path, selection_mode="all") + assert fb._selection_mode == frozenset({"file", "directory"}) + + def test_init_list_form(self, tmp_path: Path) -> None: + fb = file_browser( + initial_path=tmp_path, selection_mode=["file", "directory"] + ) + assert fb._selection_mode == frozenset({"file", "directory"}) + + def test_init_list_single(self, tmp_path: Path) -> None: + fb = file_browser(initial_path=tmp_path, selection_mode=["directory"]) + assert fb._selection_mode == frozenset({"directory"}) + + def test_init_rejects_both(self, tmp_path: Path) -> None: + with pytest.raises(ValueError): + file_browser(initial_path=tmp_path, selection_mode="both") # type: ignore[arg-type] + + def test_init_rejects_empty_list(self, tmp_path: Path) -> None: + with pytest.raises(ValueError): + file_browser(initial_path=tmp_path, selection_mode=[]) + + def test_wire_format_all(self, tmp_path: Path) -> None: + fb = file_browser(initial_path=tmp_path, selection_mode="all") + assert fb._component_args["selection-mode"] == "all" + + def test_wire_format_file(self, tmp_path: Path) -> None: + fb = file_browser(initial_path=tmp_path, selection_mode="file") + assert fb._component_args["selection-mode"] == "file" + + def test_wire_format_directory(self, tmp_path: Path) -> None: + fb = file_browser(initial_path=tmp_path, selection_mode="directory") + assert fb._component_args["selection-mode"] == "directory" + + def test_wire_format_list_normalized_to_all(self, tmp_path: Path) -> None: + fb = file_browser( + initial_path=tmp_path, + selection_mode=["directory", "file"], + ) + assert fb._component_args["selection-mode"] == "all" + + def test_list_directory_all_returns_files_and_dirs( + self, tmp_path: Path + ) -> None: + (tmp_path / "sub").mkdir() + (tmp_path / "a.txt").touch() + (tmp_path / "b.parquet").touch() + fb = file_browser(initial_path=tmp_path, selection_mode="all") + response = fb._list_directory(ListDirectoryArgs(path=str(tmp_path))) + names = {f["name"] for f in response.files} + assert names == {"sub", "a.txt", "b.parquet"} + + def test_list_directory_all_respects_filetypes_for_files( + self, tmp_path: Path + ) -> None: + (tmp_path / "sub").mkdir() + (tmp_path / "a.txt").touch() + (tmp_path / "b.parquet").touch() + fb = file_browser( + initial_path=tmp_path, + selection_mode="all", + filetypes=[".parquet"], + ) + response = fb._list_directory(ListDirectoryArgs(path=str(tmp_path))) + names = {f["name"] for f in response.files} + assert names == {"sub", "b.parquet"} + + def test_list_directory_directory_only_unchanged( + self, tmp_path: Path + ) -> None: + (tmp_path / "sub").mkdir() + (tmp_path / "a.txt").touch() + fb = file_browser(initial_path=tmp_path, selection_mode="directory") + response = fb._list_directory(ListDirectoryArgs(path=str(tmp_path))) + names = {f["name"] for f in response.files} + assert names == {"sub"}