streamlit-file-explorer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- streamlit_file_explorer/__init__.py +12 -0
- streamlit_file_explorer/component.py +145 -0
- streamlit_file_explorer/controller.py +232 -0
- streamlit_file_explorer/filesystem.py +235 -0
- streamlit_file_explorer/frontend/build/index-C1s3fNox.js +6995 -0
- streamlit_file_explorer/frontend/build/index-_hash_.css +2 -0
- streamlit_file_explorer/models.py +47 -0
- streamlit_file_explorer/py.typed +0 -0
- streamlit_file_explorer/pyproject.toml +8 -0
- streamlit_file_explorer-0.1.0.dist-info/METADATA +226 -0
- streamlit_file_explorer-0.1.0.dist-info/RECORD +15 -0
- streamlit_file_explorer-0.1.0.dist-info/WHEEL +5 -0
- streamlit_file_explorer-0.1.0.dist-info/licenses/LICENSE +202 -0
- streamlit_file_explorer-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +121 -0
- streamlit_file_explorer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Finder-style file browsing for local and cluster Streamlit applications."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .component import file_explorer
|
|
6
|
+
from .models import ExplorerResult, SelectionMode
|
|
7
|
+
|
|
8
|
+
__all__ = ["ExplorerResult", "SelectionMode", "file_explorer"]
|
|
9
|
+
try:
|
|
10
|
+
__version__ = version("streamlit-file-explorer")
|
|
11
|
+
except PackageNotFoundError:
|
|
12
|
+
__version__ = "0+uninstalled"
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Thin Streamlit adapter around the independently testable filesystem controller."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import streamlit as st
|
|
11
|
+
|
|
12
|
+
from .controller import ExplorerController
|
|
13
|
+
from .filesystem import LocalFilesystem
|
|
14
|
+
from .models import ExplorerResult, SelectionMode
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@lru_cache(maxsize=1)
|
|
18
|
+
def _get_component() -> Callable[..., Any]:
|
|
19
|
+
# Register once, on first render, so importing the public API also works in
|
|
20
|
+
# scripts and tests that do not have a Streamlit runtime.
|
|
21
|
+
return st.components.v2.component(
|
|
22
|
+
"streamlit-file-explorer.streamlit_file_explorer",
|
|
23
|
+
js="index-*.js",
|
|
24
|
+
css="*.css",
|
|
25
|
+
html='<div class="react-root"></div>',
|
|
26
|
+
isolate_styles=True,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def file_explorer(
|
|
31
|
+
roots: Mapping[str, str | Path],
|
|
32
|
+
*,
|
|
33
|
+
key: str,
|
|
34
|
+
label: str = "Files",
|
|
35
|
+
environment_label: str = "",
|
|
36
|
+
selection_mode: SelectionMode = "file",
|
|
37
|
+
multiple: bool = False,
|
|
38
|
+
extensions: Sequence[str] | None = None,
|
|
39
|
+
show_hidden: bool = False,
|
|
40
|
+
preview: bool = True,
|
|
41
|
+
height: int = 420,
|
|
42
|
+
page_size: int = 100,
|
|
43
|
+
max_entries: int = 10_000,
|
|
44
|
+
max_preview_bytes: int = 262_144,
|
|
45
|
+
cache_ttl: float = 2.0,
|
|
46
|
+
) -> ExplorerResult:
|
|
47
|
+
"""Browse explicitly configured server-side filesystem roots.
|
|
48
|
+
|
|
49
|
+
roots maps visible location names to directories. The app's OS identity
|
|
50
|
+
determines access locally and on a cluster. This component performs no writes,
|
|
51
|
+
never follows child symlinks, and requires no second HTTP server.
|
|
52
|
+
|
|
53
|
+
key must be unique and stable. Every instance and browser session has its
|
|
54
|
+
own navigation and cache. Changing filesystem/selection settings resets that
|
|
55
|
+
instance. height is the scrollable file-list height (240–500 pixels).
|
|
56
|
+
|
|
57
|
+
selection_mode accepts 'file', 'directory', or 'any'. multiple permits
|
|
58
|
+
selection within the current folder. extensions accepts suffixes such as
|
|
59
|
+
['.csv', '.tar.gz']; folders remain visible for navigation. Hidden-file
|
|
60
|
+
visibility and extension filters are UI controls, not authorization policies.
|
|
61
|
+
|
|
62
|
+
max_entries bounds scanned entries per folder. If hit, the UI explicitly
|
|
63
|
+
reports that sorting/search apply to a partial listing. max_preview_bytes
|
|
64
|
+
bounds reads; text/table rendering has additional row/character limits.
|
|
65
|
+
|
|
66
|
+
Returns an immutable ExplorerResult. confirmed is true for one run only
|
|
67
|
+
when the user presses Use. Highlighted items are available in selection.
|
|
68
|
+
Place this call and its result handling inside st.fragment to isolate reruns.
|
|
69
|
+
"""
|
|
70
|
+
if not isinstance(key, str) or not key:
|
|
71
|
+
raise ValueError("key must be a nonempty string")
|
|
72
|
+
if selection_mode not in ("file", "directory", "any"):
|
|
73
|
+
raise ValueError("selection_mode must be 'file', 'directory', or 'any'")
|
|
74
|
+
for name, flag in [("multiple", multiple), ("show_hidden", show_hidden), ("preview", preview)]:
|
|
75
|
+
if type(flag) is not bool:
|
|
76
|
+
raise ValueError(f"{name} must be a boolean")
|
|
77
|
+
if not isinstance(label, str) or not isinstance(environment_label, str):
|
|
78
|
+
raise ValueError("label and environment_label must be strings")
|
|
79
|
+
for name, value, low, high in [
|
|
80
|
+
("height", height, 240, 500),
|
|
81
|
+
("page_size", page_size, 1, 500),
|
|
82
|
+
("max_preview_bytes", max_preview_bytes, 1024, 2_097_152),
|
|
83
|
+
]:
|
|
84
|
+
if type(value) is not int or not low <= value <= high:
|
|
85
|
+
raise ValueError(f"{name} must be an integer between {low} and {high}")
|
|
86
|
+
if type(cache_ttl) not in (int, float) or not 0 <= cache_ttl <= 60:
|
|
87
|
+
raise ValueError("cache_ttl must be between 0 and 60 seconds")
|
|
88
|
+
if extensions is not None and (
|
|
89
|
+
not isinstance(extensions, Sequence)
|
|
90
|
+
or isinstance(extensions, str)
|
|
91
|
+
or any(not isinstance(s, str) for s in extensions)
|
|
92
|
+
):
|
|
93
|
+
raise ValueError("extensions must be a sequence of string suffixes")
|
|
94
|
+
suffixes = tuple(
|
|
95
|
+
sorted({s.lower() if s.startswith(".") else "." + s.lower() for s in extensions or ()})
|
|
96
|
+
)
|
|
97
|
+
if any(s == "." or "/" in s or "\\" in s for s in suffixes):
|
|
98
|
+
raise ValueError("extensions must contain valid filename suffixes")
|
|
99
|
+
fs = LocalFilesystem(roots, max_entries=max_entries)
|
|
100
|
+
config = (
|
|
101
|
+
tuple(fs.roots.items()),
|
|
102
|
+
selection_mode,
|
|
103
|
+
multiple,
|
|
104
|
+
suffixes,
|
|
105
|
+
show_hidden,
|
|
106
|
+
preview,
|
|
107
|
+
page_size,
|
|
108
|
+
max_entries,
|
|
109
|
+
max_preview_bytes,
|
|
110
|
+
cache_ttl,
|
|
111
|
+
)
|
|
112
|
+
state_key = f"_file_explorer:{key}"
|
|
113
|
+
pending_key = f"{state_key}:pending"
|
|
114
|
+
previous = st.session_state.get(state_key)
|
|
115
|
+
controller: ExplorerController
|
|
116
|
+
if previous is None or previous[0] != config:
|
|
117
|
+
controller = ExplorerController(
|
|
118
|
+
fs,
|
|
119
|
+
selection_mode=selection_mode,
|
|
120
|
+
multiple=multiple,
|
|
121
|
+
extensions=suffixes,
|
|
122
|
+
show_hidden=show_hidden,
|
|
123
|
+
preview=preview,
|
|
124
|
+
page_size=page_size,
|
|
125
|
+
max_preview_bytes=max_preview_bytes,
|
|
126
|
+
cache_ttl=cache_ttl,
|
|
127
|
+
)
|
|
128
|
+
st.session_state[state_key] = (config, controller)
|
|
129
|
+
st.session_state.pop(pending_key, None)
|
|
130
|
+
else:
|
|
131
|
+
controller = previous[1]
|
|
132
|
+
controller.apply(st.session_state.pop(pending_key, None))
|
|
133
|
+
|
|
134
|
+
def receive_action() -> None:
|
|
135
|
+
widget: Any = st.session_state.get(key, {})
|
|
136
|
+
st.session_state[pending_key] = widget.get("action")
|
|
137
|
+
|
|
138
|
+
_get_component()(
|
|
139
|
+
key=key,
|
|
140
|
+
data=controller.payload(label=label, environment=environment_label, height=height),
|
|
141
|
+
on_action_change=receive_action,
|
|
142
|
+
width="stretch",
|
|
143
|
+
height="content",
|
|
144
|
+
)
|
|
145
|
+
return controller.result()
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Per-instance navigation state and validated frontend actions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from pathlib import PurePosixPath
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .filesystem import LocalFilesystem, relative_path
|
|
12
|
+
from .models import Entry, ExplorerError, ExplorerResult, Listing, SelectionMode, SortBy
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExplorerController:
|
|
16
|
+
"""One controller per Streamlit session and component key. No global cache."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
fs: LocalFilesystem,
|
|
21
|
+
*,
|
|
22
|
+
selection_mode: SelectionMode,
|
|
23
|
+
multiple: bool,
|
|
24
|
+
extensions: tuple[str, ...],
|
|
25
|
+
show_hidden: bool,
|
|
26
|
+
preview: bool,
|
|
27
|
+
page_size: int,
|
|
28
|
+
max_preview_bytes: int,
|
|
29
|
+
cache_ttl: float,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.fs = fs
|
|
32
|
+
self.instance_id = str(uuid.uuid4())
|
|
33
|
+
self.selection_mode = selection_mode
|
|
34
|
+
self.multiple = multiple
|
|
35
|
+
self.extensions = extensions
|
|
36
|
+
self.root = next(iter(fs.roots))
|
|
37
|
+
self.path = ""
|
|
38
|
+
self.selected: list[str] = []
|
|
39
|
+
self.query = ""
|
|
40
|
+
self.sort: SortBy = "name"
|
|
41
|
+
self.descending = False
|
|
42
|
+
self.page = 0
|
|
43
|
+
self.show_hidden = show_hidden
|
|
44
|
+
self.show_preview = preview
|
|
45
|
+
self.page_size = page_size
|
|
46
|
+
self.max_preview_bytes = max_preview_bytes
|
|
47
|
+
self.cache_ttl = cache_ttl
|
|
48
|
+
self.error: str | None = None
|
|
49
|
+
self.confirmed = False
|
|
50
|
+
self.ack = ""
|
|
51
|
+
self.revision = 0
|
|
52
|
+
self._listing: tuple[tuple[str, str, bool], float, Listing] | None = None
|
|
53
|
+
|
|
54
|
+
def apply(self, event: Any) -> None:
|
|
55
|
+
self.confirmed = False
|
|
56
|
+
if not isinstance(event, dict):
|
|
57
|
+
return
|
|
58
|
+
action_id = event.get("id")
|
|
59
|
+
if not isinstance(action_id, str) or not action_id or len(action_id) > 128:
|
|
60
|
+
self.error = "Invalid explorer action. Refresh the folder."
|
|
61
|
+
return
|
|
62
|
+
if action_id == self.ack:
|
|
63
|
+
return
|
|
64
|
+
self.ack = action_id
|
|
65
|
+
self.revision += 1
|
|
66
|
+
self.error = None
|
|
67
|
+
try:
|
|
68
|
+
# Reject delayed actions from a folder that has already been left.
|
|
69
|
+
if event.get("context") != {"root": self.root, "path": self.path}:
|
|
70
|
+
return
|
|
71
|
+
action = event.get("type")
|
|
72
|
+
if action == "navigate":
|
|
73
|
+
target_root = event.get("root")
|
|
74
|
+
if not isinstance(target_root, str):
|
|
75
|
+
raise ExplorerError("Invalid location.")
|
|
76
|
+
target_path = relative_path(event.get("path"))
|
|
77
|
+
if self.fs.inspect(target_root, target_path).kind != "directory":
|
|
78
|
+
raise ExplorerError("This item is not a folder.")
|
|
79
|
+
self.root, self.path = target_root, target_path
|
|
80
|
+
self.selected, self.query, self.page = [], "", 0
|
|
81
|
+
self._listing = None
|
|
82
|
+
elif action == "select":
|
|
83
|
+
self.selected = self._validate_selection(event.get("paths"), confirming=False)
|
|
84
|
+
elif action == "confirm":
|
|
85
|
+
selection = self._validate_selection(event.get("paths"), confirming=True)
|
|
86
|
+
if not selection:
|
|
87
|
+
raise ExplorerError("Select an item first.")
|
|
88
|
+
self.selected, self.confirmed = selection, True
|
|
89
|
+
elif action == "search":
|
|
90
|
+
query = event.get("query")
|
|
91
|
+
if not isinstance(query, str) or len(query) > 1000:
|
|
92
|
+
raise ExplorerError("Search must be at most 1,000 characters.")
|
|
93
|
+
self.query, self.page = query, 0
|
|
94
|
+
elif action == "sort":
|
|
95
|
+
sort = event.get("sort")
|
|
96
|
+
descending = event.get("descending")
|
|
97
|
+
if sort not in ("name", "size", "modified", "type") or type(descending) is not bool:
|
|
98
|
+
raise ExplorerError("Invalid sort option.")
|
|
99
|
+
self.sort, self.descending, self.page = sort, descending, 0
|
|
100
|
+
elif action == "page":
|
|
101
|
+
page = event.get("page")
|
|
102
|
+
if type(page) is not int or not 0 <= page <= self.fs.max_entries:
|
|
103
|
+
raise ExplorerError("Invalid page.")
|
|
104
|
+
self.page = page
|
|
105
|
+
elif action == "hidden":
|
|
106
|
+
self.show_hidden = not self.show_hidden
|
|
107
|
+
self.page = 0
|
|
108
|
+
self._listing = None
|
|
109
|
+
elif action == "preview":
|
|
110
|
+
self.show_preview = not self.show_preview
|
|
111
|
+
elif action == "refresh":
|
|
112
|
+
self._listing = None
|
|
113
|
+
else:
|
|
114
|
+
raise ExplorerError("Unknown explorer action.")
|
|
115
|
+
except ExplorerError as exc:
|
|
116
|
+
self.error = str(exc)
|
|
117
|
+
|
|
118
|
+
def _validate_selection(self, paths: Any, *, confirming: bool) -> list[str]:
|
|
119
|
+
if not isinstance(paths, list) or len(paths) > (1000 if self.multiple else 1):
|
|
120
|
+
raise ExplorerError("Invalid selection.")
|
|
121
|
+
validated: list[str] = []
|
|
122
|
+
for value in paths:
|
|
123
|
+
path = relative_path(value)
|
|
124
|
+
parent = str(PurePosixPath(path).parent)
|
|
125
|
+
parent = "" if parent == "." else parent
|
|
126
|
+
if path != self.path and parent != self.path:
|
|
127
|
+
raise ExplorerError("Select items from the current folder.")
|
|
128
|
+
entry = self.fs.inspect(self.root, path)
|
|
129
|
+
if (
|
|
130
|
+
entry.kind == "file"
|
|
131
|
+
and self.extensions
|
|
132
|
+
and not path.lower().endswith(self.extensions)
|
|
133
|
+
):
|
|
134
|
+
raise ExplorerError("This file type is not allowed by the app.")
|
|
135
|
+
if confirming and not self.can_select(entry):
|
|
136
|
+
raise ExplorerError(f"Choose a {self.selection_mode} for this picker.")
|
|
137
|
+
if path not in validated:
|
|
138
|
+
validated.append(path)
|
|
139
|
+
return validated
|
|
140
|
+
|
|
141
|
+
def can_select(self, entry: Entry) -> bool:
|
|
142
|
+
return self.selection_mode == "any" or entry.kind == self.selection_mode
|
|
143
|
+
|
|
144
|
+
def _read_listing(self) -> Listing:
|
|
145
|
+
key = (self.root, self.path, self.show_hidden)
|
|
146
|
+
now = time.monotonic()
|
|
147
|
+
if self._listing and self._listing[0] == key and now - self._listing[1] < self.cache_ttl:
|
|
148
|
+
return self._listing[2]
|
|
149
|
+
listing = self.fs.list_directory(self.root, self.path, show_hidden=self.show_hidden)
|
|
150
|
+
self._listing = (key, now, listing)
|
|
151
|
+
return listing
|
|
152
|
+
|
|
153
|
+
def payload(self, *, label: str, environment: str, height: int) -> dict[str, Any]:
|
|
154
|
+
listing = Listing(())
|
|
155
|
+
selected_entries = []
|
|
156
|
+
preview: dict[str, Any] | None = None
|
|
157
|
+
try:
|
|
158
|
+
listing = self._read_listing()
|
|
159
|
+
# Revalidate references even when the listing came from cache.
|
|
160
|
+
for path in self.selected:
|
|
161
|
+
selected_entries.append(self.fs.inspect(self.root, path))
|
|
162
|
+
except ExplorerError as exc:
|
|
163
|
+
self.error = str(exc)
|
|
164
|
+
self.selected, selected_entries, self.confirmed = [], [], False
|
|
165
|
+
if self.show_preview and selected_entries:
|
|
166
|
+
try:
|
|
167
|
+
preview = self.fs.preview(
|
|
168
|
+
self.root, selected_entries[-1].path, max_bytes=self.max_preview_bytes
|
|
169
|
+
)
|
|
170
|
+
except ExplorerError as exc:
|
|
171
|
+
preview = {"kind": "error", "message": str(exc)}
|
|
172
|
+
entries = [
|
|
173
|
+
e
|
|
174
|
+
for e in listing.entries
|
|
175
|
+
if (not self.extensions or e.kind != "file" or e.name.lower().endswith(self.extensions))
|
|
176
|
+
and self.query.casefold() in e.name.casefold()
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
def sort_key(entry: Entry) -> tuple[Any, str]:
|
|
180
|
+
if self.sort == "size":
|
|
181
|
+
return (entry.size or 0), entry.name.casefold()
|
|
182
|
+
if self.sort == "modified":
|
|
183
|
+
return (entry.modified or 0), entry.name.casefold()
|
|
184
|
+
if self.sort == "type":
|
|
185
|
+
return PurePosixPath(entry.name).suffix.casefold(), entry.name.casefold()
|
|
186
|
+
return entry.name.casefold(), entry.name
|
|
187
|
+
|
|
188
|
+
entries.sort(key=sort_key, reverse=self.descending)
|
|
189
|
+
entries.sort(key=lambda entry: entry.kind != "directory")
|
|
190
|
+
pages = max(1, (len(entries) + self.page_size - 1) // self.page_size)
|
|
191
|
+
self.page = min(self.page, pages - 1)
|
|
192
|
+
start = self.page * self.page_size
|
|
193
|
+
return {
|
|
194
|
+
"instance_id": self.instance_id,
|
|
195
|
+
"label": label,
|
|
196
|
+
"environment": environment,
|
|
197
|
+
"height": height,
|
|
198
|
+
"locations": [{"id": name, "label": name} for name in self.fs.roots],
|
|
199
|
+
"root": self.root,
|
|
200
|
+
"path": self.path,
|
|
201
|
+
"display_path": str(self.fs.path(self.root, self.path)),
|
|
202
|
+
"entries": [asdict(entry) for entry in entries[start : start + self.page_size]],
|
|
203
|
+
"total": len(entries),
|
|
204
|
+
"page": self.page,
|
|
205
|
+
"pages": pages,
|
|
206
|
+
"page_size": self.page_size,
|
|
207
|
+
"truncated": listing.truncated,
|
|
208
|
+
"max_entries": self.fs.max_entries,
|
|
209
|
+
"selected": self.selected,
|
|
210
|
+
"selected_entries": [asdict(entry) for entry in selected_entries],
|
|
211
|
+
"query": self.query,
|
|
212
|
+
"sort": self.sort,
|
|
213
|
+
"descending": self.descending,
|
|
214
|
+
"show_hidden": self.show_hidden,
|
|
215
|
+
"show_preview": self.show_preview,
|
|
216
|
+
"preview": preview,
|
|
217
|
+
"multiple": self.multiple,
|
|
218
|
+
"selection_mode": self.selection_mode,
|
|
219
|
+
"can_confirm": bool(selected_entries)
|
|
220
|
+
and all(self.can_select(e) for e in selected_entries),
|
|
221
|
+
"error": self.error,
|
|
222
|
+
"ack": self.ack,
|
|
223
|
+
"revision": self.revision,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
def result(self) -> ExplorerResult:
|
|
227
|
+
return ExplorerResult(
|
|
228
|
+
directory=self.fs.path(self.root, self.path),
|
|
229
|
+
selection=tuple(self.fs.path(self.root, path) for path in self.selected),
|
|
230
|
+
confirmed=self.confirmed,
|
|
231
|
+
error=self.error,
|
|
232
|
+
)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Bounded, read-only filesystem access without recursive traversal or shared caches."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import codecs
|
|
7
|
+
import csv
|
|
8
|
+
import errno
|
|
9
|
+
import io
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
from collections.abc import Iterator, Mapping
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .models import Entry, ExplorerError, Listing
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def relative_path(value: object) -> str:
|
|
21
|
+
"""Validate the platform-independent path format used by the frontend."""
|
|
22
|
+
if not isinstance(value, str) or len(value) > 4096 or "\x00" in value:
|
|
23
|
+
raise ExplorerError("Invalid path.")
|
|
24
|
+
path = PurePosixPath(value)
|
|
25
|
+
if path.is_absolute() or ".." in path.parts or "\\" in value or PureWindowsPath(value).drive:
|
|
26
|
+
raise ExplorerError("Choose a path inside a configured location.")
|
|
27
|
+
return "" if str(path) == "." else path.as_posix()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def friendly_error(exc: OSError) -> str:
|
|
31
|
+
if exc.errno in {errno.EACCES, errno.EPERM}:
|
|
32
|
+
return "Permission denied. The app's account cannot access this item."
|
|
33
|
+
if exc.errno == errno.ENOENT:
|
|
34
|
+
return "This item is no longer available. Refresh the folder."
|
|
35
|
+
if exc.errno in {errno.ELOOP, errno.ENOTDIR}:
|
|
36
|
+
return "This path is a symbolic link or is no longer a folder."
|
|
37
|
+
return "The filesystem could not read this item. Try refreshing the folder."
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LocalFilesystem:
|
|
41
|
+
"""Access explicit filesystem roots using the Streamlit process's OS identity.
|
|
42
|
+
|
|
43
|
+
Symbolic links below a root are never followed. On POSIX, reads walk directory
|
|
44
|
+
descriptors with O_NOFOLLOW, so replacing a child with a symlink cannot redirect
|
|
45
|
+
the read. Roots and their ancestors must be controlled by the deploying app.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, roots: Mapping[str, str | Path], *, max_entries: int = 10_000):
|
|
49
|
+
if (
|
|
50
|
+
not isinstance(roots, Mapping)
|
|
51
|
+
or not roots
|
|
52
|
+
or any(not isinstance(label, str) or not label.strip() for label in roots)
|
|
53
|
+
):
|
|
54
|
+
raise ValueError("roots must contain at least one nonempty location label")
|
|
55
|
+
if type(max_entries) is not int or not 1 <= max_entries <= 100_000:
|
|
56
|
+
raise ValueError("max_entries must be between 1 and 100,000")
|
|
57
|
+
if any(not isinstance(path, (str, Path)) for path in roots.values()):
|
|
58
|
+
raise ValueError("root paths must be strings or pathlib.Path objects")
|
|
59
|
+
self.roots = {label: Path(path).expanduser().resolve() for label, path in roots.items()}
|
|
60
|
+
self.max_entries = max_entries
|
|
61
|
+
|
|
62
|
+
def path(self, root: str, relative: object) -> Path:
|
|
63
|
+
if root not in self.roots:
|
|
64
|
+
raise ExplorerError("Unknown filesystem location.")
|
|
65
|
+
return self.roots[root] / relative_path(relative)
|
|
66
|
+
|
|
67
|
+
@contextmanager
|
|
68
|
+
def _open(self, root: str, relative: object, *, directory: bool = False) -> Iterator[int]:
|
|
69
|
+
rel = relative_path(relative)
|
|
70
|
+
path = self.path(root, rel)
|
|
71
|
+
descriptors: list[int] = []
|
|
72
|
+
try:
|
|
73
|
+
if os.open in os.supports_dir_fd and hasattr(os, "O_NOFOLLOW"):
|
|
74
|
+
flags = os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0)
|
|
75
|
+
fd = os.open(self.roots[root], flags | os.O_DIRECTORY)
|
|
76
|
+
descriptors.append(fd)
|
|
77
|
+
parts = PurePosixPath(rel).parts
|
|
78
|
+
for index, part in enumerate(parts):
|
|
79
|
+
child_flags = flags
|
|
80
|
+
if index < len(parts) - 1 or directory:
|
|
81
|
+
child_flags |= os.O_DIRECTORY
|
|
82
|
+
fd = os.open(part, child_flags, dir_fd=fd)
|
|
83
|
+
descriptors.append(fd)
|
|
84
|
+
else:
|
|
85
|
+
# Portable fallback. OS permissions remain the security boundary;
|
|
86
|
+
# concurrent path replacement is not defended on this platform.
|
|
87
|
+
current = self.roots[root]
|
|
88
|
+
for part in PurePosixPath(rel).parts:
|
|
89
|
+
current /= part
|
|
90
|
+
if current.is_symlink():
|
|
91
|
+
raise ExplorerError("Symbolic links are not followed.")
|
|
92
|
+
fd = os.open(path, os.O_RDONLY | getattr(os, "O_BINARY", 0))
|
|
93
|
+
descriptors.append(fd)
|
|
94
|
+
mode = os.fstat(fd).st_mode
|
|
95
|
+
if directory and not stat.S_ISDIR(mode):
|
|
96
|
+
raise ExplorerError("This item is not a folder.")
|
|
97
|
+
if not directory and not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
|
|
98
|
+
raise ExplorerError("Only regular files and folders can be selected.")
|
|
99
|
+
yield fd
|
|
100
|
+
except OSError as exc:
|
|
101
|
+
raise ExplorerError(friendly_error(exc)) from exc
|
|
102
|
+
finally:
|
|
103
|
+
for descriptor in reversed(descriptors):
|
|
104
|
+
os.close(descriptor)
|
|
105
|
+
|
|
106
|
+
def inspect(self, root: str, relative: object) -> Entry:
|
|
107
|
+
rel = relative_path(relative)
|
|
108
|
+
with self._open(root, rel) as fd:
|
|
109
|
+
info = os.fstat(fd)
|
|
110
|
+
return Entry(
|
|
111
|
+
name=PurePosixPath(rel).name or root,
|
|
112
|
+
path=rel,
|
|
113
|
+
kind="directory" if stat.S_ISDIR(info.st_mode) else "file",
|
|
114
|
+
size=info.st_size if stat.S_ISREG(info.st_mode) else None,
|
|
115
|
+
modified=info.st_mtime,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def list_directory(self, root: str, relative: object, *, show_hidden: bool) -> Listing:
|
|
119
|
+
rel = relative_path(relative)
|
|
120
|
+
entries: list[Entry] = []
|
|
121
|
+
truncated = False
|
|
122
|
+
with self._open(root, rel, directory=True) as fd:
|
|
123
|
+
source: int | Path = fd if os.scandir in os.supports_fd else self.path(root, rel)
|
|
124
|
+
with os.scandir(source) as items:
|
|
125
|
+
for index, item in enumerate(items):
|
|
126
|
+
if index >= self.max_entries:
|
|
127
|
+
truncated = True
|
|
128
|
+
break
|
|
129
|
+
if not show_hidden and item.name.startswith("."):
|
|
130
|
+
continue
|
|
131
|
+
child = f"{rel}/{item.name}" if rel else item.name
|
|
132
|
+
try:
|
|
133
|
+
relative_path(child)
|
|
134
|
+
# Undecodable Unix filenames cannot be transported by JSON.
|
|
135
|
+
item.name.encode("utf-8")
|
|
136
|
+
info = item.stat(follow_symlinks=False)
|
|
137
|
+
mode = info.st_mode
|
|
138
|
+
kind: Any = (
|
|
139
|
+
"symlink"
|
|
140
|
+
if stat.S_ISLNK(mode)
|
|
141
|
+
else "directory"
|
|
142
|
+
if stat.S_ISDIR(mode)
|
|
143
|
+
else "file"
|
|
144
|
+
if stat.S_ISREG(mode)
|
|
145
|
+
else "other"
|
|
146
|
+
)
|
|
147
|
+
error = None
|
|
148
|
+
if kind == "symlink":
|
|
149
|
+
error = "Symbolic links are not followed."
|
|
150
|
+
elif kind == "other":
|
|
151
|
+
error = "Special filesystem entries cannot be opened."
|
|
152
|
+
entries.append(
|
|
153
|
+
Entry(
|
|
154
|
+
item.name,
|
|
155
|
+
child,
|
|
156
|
+
kind,
|
|
157
|
+
info.st_size if kind == "file" else None,
|
|
158
|
+
info.st_mtime,
|
|
159
|
+
error,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
except (OSError, ExplorerError, UnicodeError):
|
|
163
|
+
# Include an unavailable row without leaking invalid Unicode.
|
|
164
|
+
safe_name = item.name.encode("utf-8", errors="replace").decode()
|
|
165
|
+
entries.append(
|
|
166
|
+
Entry(
|
|
167
|
+
safe_name,
|
|
168
|
+
"",
|
|
169
|
+
"unavailable",
|
|
170
|
+
None,
|
|
171
|
+
None,
|
|
172
|
+
"This entry is unavailable or has an unsupported name.",
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return Listing(tuple(entries), truncated)
|
|
176
|
+
|
|
177
|
+
def preview(self, root: str, relative: str, *, max_bytes: int) -> dict[str, Any]:
|
|
178
|
+
"""Never deserialize documents, execute markup, or read an unbounded file."""
|
|
179
|
+
with self._open(root, relative) as fd:
|
|
180
|
+
info = os.fstat(fd)
|
|
181
|
+
if stat.S_ISDIR(info.st_mode):
|
|
182
|
+
return {"kind": "directory", "message": "Open this folder to browse its contents."}
|
|
183
|
+
# read() is bounded even if a file grows after fstat().
|
|
184
|
+
with os.fdopen(os.dup(fd), "rb") as stream:
|
|
185
|
+
raw = stream.read(max_bytes + 1)
|
|
186
|
+
truncated = len(raw) > max_bytes
|
|
187
|
+
raw = raw[:max_bytes]
|
|
188
|
+
image_type = _image_type(raw)
|
|
189
|
+
if image_type:
|
|
190
|
+
if truncated:
|
|
191
|
+
return {"kind": "metadata", "message": "Image exceeds the preview size limit."}
|
|
192
|
+
return {
|
|
193
|
+
"kind": "image",
|
|
194
|
+
"src": f"data:{image_type};base64," + base64.b64encode(raw).decode("ascii"),
|
|
195
|
+
}
|
|
196
|
+
if b"\x00" in raw:
|
|
197
|
+
return {"kind": "metadata", "message": "Preview is unavailable for this binary file."}
|
|
198
|
+
try:
|
|
199
|
+
text = codecs.getincrementaldecoder("utf-8-sig")().decode(raw, final=not truncated)
|
|
200
|
+
except UnicodeError:
|
|
201
|
+
return {"kind": "metadata", "message": "Preview supports UTF-8 text and small images."}
|
|
202
|
+
suffix = PurePosixPath(relative).suffix.lower()
|
|
203
|
+
if suffix in {".csv", ".tsv"}:
|
|
204
|
+
try:
|
|
205
|
+
rows = []
|
|
206
|
+
for index, row in enumerate(
|
|
207
|
+
csv.reader(io.StringIO(text), delimiter="\t" if suffix == ".tsv" else ",")
|
|
208
|
+
):
|
|
209
|
+
if index == 51:
|
|
210
|
+
truncated = True
|
|
211
|
+
break
|
|
212
|
+
truncated |= len(row) > 20 or any(len(cell) > 300 for cell in row)
|
|
213
|
+
rows.append([cell[:300] for cell in row[:20]])
|
|
214
|
+
return {"kind": "table", "rows": rows, "truncated": truncated}
|
|
215
|
+
except csv.Error:
|
|
216
|
+
pass
|
|
217
|
+
lines = text.splitlines()
|
|
218
|
+
clipped = "\n".join(lines[:200])[:20_000]
|
|
219
|
+
return {
|
|
220
|
+
"kind": "text",
|
|
221
|
+
"text": clipped,
|
|
222
|
+
"truncated": truncated or len(lines) > 200 or len(text) > 20_000,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _image_type(raw: bytes) -> str | None:
|
|
227
|
+
if raw.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
228
|
+
return "image/png"
|
|
229
|
+
if raw.startswith(b"\xff\xd8\xff"):
|
|
230
|
+
return "image/jpeg"
|
|
231
|
+
if raw[:6] in {b"GIF87a", b"GIF89a"}:
|
|
232
|
+
return "image/gif"
|
|
233
|
+
if raw.startswith(b"RIFF") and raw[8:12] == b"WEBP":
|
|
234
|
+
return "image/webp"
|
|
235
|
+
return None
|