Skip to content

Commit 9a149d6

Browse files
authored
feat(file_browser): restore selection_mode='all' and accept list form (#9630)
1 parent 61be008 commit 9a149d6

3 files changed

Lines changed: 229 additions & 13 deletions

File tree

examples/ui/file_browser.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,18 @@ def _(file_browser):
2424
return
2525

2626

27+
@app.cell
28+
def _(mo):
29+
file_browser_all = mo.ui.file_browser(selection_mode="all")
30+
file_browser_all
31+
return (file_browser_all,)
32+
33+
34+
@app.cell
35+
def _(file_browser_all):
36+
file_browser_all.value
37+
return
38+
39+
2740
if __name__ == "__main__":
2841
app.run()

marimo/_plugins/ui/_impl/file_browser.py

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,57 @@
1414
from marimo import _loggers
1515
from marimo._output.rich_help import mddoc
1616
from marimo._plugins.ui._core.ui_element import UIElement
17-
from marimo._plugins.validators import validate_one_of
1817
from marimo._runtime.functions import Function
1918
from marimo._utils.files import natural_sort
2019
from marimo._utils.paths import is_cloudpath, normalize_path
2120

2221
LOGGER = _loggers.marimo_logger()
2322

2423

24+
_VALID_KINDS: Final[frozenset[str]] = frozenset({"file", "directory"})
25+
26+
27+
def _normalize_selection_mode(
28+
value: object,
29+
) -> frozenset[str]:
30+
"""Normalize ``selection_mode`` to a frozenset of selectable kinds.
31+
32+
Accepted inputs:
33+
- ``"file"`` -> ``{"file"}``
34+
- ``"directory"`` -> ``{"directory"}``
35+
- ``"all"`` -> ``{"file", "directory"}``
36+
- list/tuple containing ``"file"`` and/or ``"directory"`` (deduped)
37+
"""
38+
if isinstance(value, str):
39+
if value == "all":
40+
return frozenset(_VALID_KINDS)
41+
if value in _VALID_KINDS:
42+
return frozenset({value})
43+
raise ValueError(
44+
f"Invalid selection_mode {value!r}. "
45+
f"Expected one of 'file', 'directory', 'all', "
46+
f"or a list of 'file'/'directory'."
47+
)
48+
49+
if isinstance(value, (list, tuple)):
50+
if len(value) == 0:
51+
raise ValueError("selection_mode list must not be empty.")
52+
kinds: set[str] = set()
53+
for kind in value:
54+
if not isinstance(kind, str) or kind not in _VALID_KINDS:
55+
raise ValueError(
56+
f"Invalid selection_mode entry {kind!r}. "
57+
f"Each entry must be 'file' or 'directory'."
58+
)
59+
kinds.add(kind)
60+
return frozenset(kinds)
61+
62+
raise ValueError(
63+
f"selection_mode must be a string or a list of strings, "
64+
f"got {type(value).__name__}."
65+
)
66+
67+
2568
@dataclass
2669
class ListDirectoryArgs:
2770
path: str
@@ -70,6 +113,16 @@ class file_browser(
70113
file_browser.name(index=0)
71114
```
72115
116+
Selecting both files and directories (useful for formats like
117+
deltalake that are stored as directories):
118+
```python
119+
file_browser = mo.ui.file_browser(
120+
initial_path=Path("path/to/dir"),
121+
selection_mode="all",
122+
# Equivalent: selection_mode=["file", "directory"]
123+
)
124+
```
125+
73126
Connecting to an S3 (or GCS, Azure) bucket:
74127
```python
75128
from cloudpathlib import S3Path
@@ -114,8 +167,10 @@ class file_browser(
114167
filetypes (Sequence[str], optional): The file types to display in each
115168
directory; for example, filetypes=[".txt", ".csv"]. If None, all
116169
files are displayed. Defaults to None.
117-
selection_mode (Literal["file", "directory"], optional): Either "file" or "directory". Defaults to
118-
"file".
170+
selection_mode (str | Sequence[str], optional): Which kinds of entries
171+
the user can select. Accepts one of "file" (default), "directory",
172+
"all", or a list/tuple containing "file" and/or "directory".
173+
"all" is equivalent to ["file", "directory"]. Defaults to "file".
119174
multiple (bool, optional): If True, allow the user to select multiple
120175
files. Defaults to True.
121176
restrict_navigation (bool, optional): If True, prevent the user from
@@ -140,7 +195,8 @@ def __init__(
140195
self,
141196
initial_path: str | Path = "",
142197
filetypes: Sequence[str] | None = None,
143-
selection_mode: Literal["file", "directory"] = "file",
198+
selection_mode: Literal["file", "directory", "all"]
199+
| Sequence[Literal["file", "directory"]] = "file",
144200
multiple: bool = True,
145201
restrict_navigation: bool = False,
146202
*,
@@ -150,7 +206,7 @@ def __init__(
150206
| None = None,
151207
ignore_empty_dirs: bool = False,
152208
) -> None:
153-
validate_one_of(selection_mode, ["file", "directory"])
209+
self._selection_mode = _normalize_selection_mode(selection_mode)
154210

155211
# Save the Path class of the initial path
156212
self._path_cls: type[Path]
@@ -176,7 +232,6 @@ def __init__(
176232
f"Initial path {initial_path} is not a directory."
177233
)
178234

179-
self._selection_mode = selection_mode
180235
# Normalize filetypes: ensure lowercase and dot prefix for case-insensitive matching
181236
if filetypes:
182237
normalized_filetypes = set()
@@ -201,13 +256,18 @@ def __init__(
201256

202257
self._limit = limit
203258

259+
if self._selection_mode == _VALID_KINDS:
260+
wire_selection_mode = "all"
261+
else:
262+
(wire_selection_mode,) = self._selection_mode
263+
204264
super().__init__(
205265
component_name=file_browser._name,
206266
initial_value=[],
207267
label=label,
208268
args={
209269
"initial-path": str(self._initial_path),
210-
"selection-mode": selection_mode,
270+
"selection-mode": wire_selection_mode,
211271
"filetypes": filetypes if filetypes is not None else [],
212272
"multiple": multiple,
213273
"restrict-navigation": restrict_navigation,
@@ -316,8 +376,9 @@ def _list_directory(
316376
extension = file.suffix
317377
is_directory = file.is_dir() # Expensive call for cloud paths
318378

319-
# Skip non-directories if selection mode is directory
320-
if self._selection_mode == "directory" and not is_directory:
379+
# Directories are always shown so the user can navigate into
380+
# them. Files are hidden when files aren't selectable.
381+
if not is_directory and "file" not in self._selection_mode:
321382
continue
322383

323384
# Skip non-matching file types (case-insensitive)

tests/_plugins/ui/_impl/test_file_browser.py

Lines changed: 146 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
FileBrowserFileInfo,
1212
ListDirectoryArgs,
1313
ListDirectoryResponse,
14+
_normalize_selection_mode,
1415
file_browser,
1516
)
1617
from marimo._utils.paths import normalize_path
@@ -21,7 +22,7 @@ def test_file_browser_init(tmp_path: Path) -> None:
2122
fb = file_browser(initial_path=tmp_path)
2223
assert isinstance(fb._initial_path, Path)
2324
assert str(fb._initial_path) == str(normalize_path(tmp_path))
24-
assert fb._selection_mode == "file"
25+
assert fb._selection_mode == frozenset({"file"})
2526
assert fb._filetypes == set()
2627
assert fb._restrict_navigation is False
2728

@@ -35,7 +36,7 @@ def test_file_browser_init(tmp_path: Path) -> None:
3536
)
3637
assert fb._initial_path == normalize_path(tmp_path)
3738
assert fb._filetypes == set(custom_filetypes)
38-
assert fb._selection_mode == "directory"
39+
assert fb._selection_mode == frozenset({"directory"})
3940
assert fb._restrict_navigation is True
4041

4142

@@ -352,8 +353,8 @@ def resolve(self) -> CustomPathWithClient:
352353

353354
def test_validation() -> None:
354355
with pytest.raises(ValueError) as e:
355-
file_browser(initial_path="invalid", selection_mode="invalid")
356-
assert "Value must be one of" in str(e.value)
356+
file_browser(initial_path="invalid", selection_mode="invalid") # type: ignore[arg-type]
357+
assert "Invalid selection_mode" in str(e.value)
357358

358359

359360
def test_limit_arg(tmp_path: Path) -> None:
@@ -1090,3 +1091,144 @@ def test_file_browser_relative_path_sent_to_frontend_as_absolute(
10901091

10911092
finally:
10921093
os.chdir(original_cwd)
1094+
1095+
1096+
class TestNormalizeSelectionMode:
1097+
def test_string_file(self) -> None:
1098+
assert _normalize_selection_mode("file") == frozenset({"file"})
1099+
1100+
def test_string_directory(self) -> None:
1101+
assert _normalize_selection_mode("directory") == frozenset(
1102+
{"directory"}
1103+
)
1104+
1105+
def test_string_all(self) -> None:
1106+
assert _normalize_selection_mode("all") == frozenset(
1107+
{"file", "directory"}
1108+
)
1109+
1110+
def test_list_single_file(self) -> None:
1111+
assert _normalize_selection_mode(["file"]) == frozenset({"file"})
1112+
1113+
def test_list_single_directory(self) -> None:
1114+
assert _normalize_selection_mode(["directory"]) == frozenset(
1115+
{"directory"}
1116+
)
1117+
1118+
def test_list_both(self) -> None:
1119+
assert _normalize_selection_mode(["file", "directory"]) == frozenset(
1120+
{"file", "directory"}
1121+
)
1122+
1123+
def test_list_order_independent(self) -> None:
1124+
assert _normalize_selection_mode(["directory", "file"]) == frozenset(
1125+
{"file", "directory"}
1126+
)
1127+
1128+
def test_list_dedup(self) -> None:
1129+
assert _normalize_selection_mode(["file", "file"]) == frozenset(
1130+
{"file"}
1131+
)
1132+
1133+
def test_tuple_accepted(self) -> None:
1134+
assert _normalize_selection_mode(("file", "directory")) == frozenset(
1135+
{"file", "directory"}
1136+
)
1137+
1138+
@pytest.mark.parametrize(
1139+
"value",
1140+
[
1141+
"both",
1142+
"folder",
1143+
"",
1144+
"FILE",
1145+
[],
1146+
["both"],
1147+
["file", "nope"],
1148+
["file", 1],
1149+
None,
1150+
123,
1151+
],
1152+
)
1153+
def test_rejects_invalid(self, value: Any) -> None:
1154+
with pytest.raises(ValueError):
1155+
_normalize_selection_mode(value)
1156+
1157+
1158+
class TestSelectionModeAll:
1159+
def test_init_all_string(self, tmp_path: Path) -> None:
1160+
fb = file_browser(initial_path=tmp_path, selection_mode="all")
1161+
assert fb._selection_mode == frozenset({"file", "directory"})
1162+
1163+
def test_init_list_form(self, tmp_path: Path) -> None:
1164+
fb = file_browser(
1165+
initial_path=tmp_path, selection_mode=["file", "directory"]
1166+
)
1167+
assert fb._selection_mode == frozenset({"file", "directory"})
1168+
1169+
def test_init_list_single(self, tmp_path: Path) -> None:
1170+
fb = file_browser(initial_path=tmp_path, selection_mode=["directory"])
1171+
assert fb._selection_mode == frozenset({"directory"})
1172+
1173+
def test_init_rejects_both(self, tmp_path: Path) -> None:
1174+
with pytest.raises(ValueError):
1175+
file_browser(initial_path=tmp_path, selection_mode="both") # type: ignore[arg-type]
1176+
1177+
def test_init_rejects_empty_list(self, tmp_path: Path) -> None:
1178+
with pytest.raises(ValueError):
1179+
file_browser(initial_path=tmp_path, selection_mode=[])
1180+
1181+
def test_wire_format_all(self, tmp_path: Path) -> None:
1182+
fb = file_browser(initial_path=tmp_path, selection_mode="all")
1183+
assert fb._component_args["selection-mode"] == "all"
1184+
1185+
def test_wire_format_file(self, tmp_path: Path) -> None:
1186+
fb = file_browser(initial_path=tmp_path, selection_mode="file")
1187+
assert fb._component_args["selection-mode"] == "file"
1188+
1189+
def test_wire_format_directory(self, tmp_path: Path) -> None:
1190+
fb = file_browser(initial_path=tmp_path, selection_mode="directory")
1191+
assert fb._component_args["selection-mode"] == "directory"
1192+
1193+
def test_wire_format_list_normalized_to_all(self, tmp_path: Path) -> None:
1194+
fb = file_browser(
1195+
initial_path=tmp_path,
1196+
selection_mode=["directory", "file"],
1197+
)
1198+
assert fb._component_args["selection-mode"] == "all"
1199+
1200+
def test_list_directory_all_returns_files_and_dirs(
1201+
self, tmp_path: Path
1202+
) -> None:
1203+
(tmp_path / "sub").mkdir()
1204+
(tmp_path / "a.txt").touch()
1205+
(tmp_path / "b.parquet").touch()
1206+
fb = file_browser(initial_path=tmp_path, selection_mode="all")
1207+
response = fb._list_directory(ListDirectoryArgs(path=str(tmp_path)))
1208+
names = {f["name"] for f in response.files}
1209+
assert names == {"sub", "a.txt", "b.parquet"}
1210+
1211+
def test_list_directory_all_respects_filetypes_for_files(
1212+
self, tmp_path: Path
1213+
) -> None:
1214+
(tmp_path / "sub").mkdir()
1215+
(tmp_path / "a.txt").touch()
1216+
(tmp_path / "b.parquet").touch()
1217+
fb = file_browser(
1218+
initial_path=tmp_path,
1219+
selection_mode="all",
1220+
filetypes=[".parquet"],
1221+
)
1222+
response = fb._list_directory(ListDirectoryArgs(path=str(tmp_path)))
1223+
names = {f["name"] for f in response.files}
1224+
assert names == {"sub", "b.parquet"}
1225+
1226+
def test_list_directory_directory_only_unchanged(
1227+
self, tmp_path: Path
1228+
) -> None:
1229+
(tmp_path / "sub").mkdir()
1230+
(tmp_path / "a.txt").touch()
1231+
fb = file_browser(initial_path=tmp_path, selection_mode="directory")
1232+
response = fb._list_directory(ListDirectoryArgs(path=str(tmp_path)))
1233+
names = {f["name"] for f in response.files}
1234+
assert names == {"sub"}

0 commit comments

Comments
 (0)