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.
@@ -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