streamlit-command-palette 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,24 @@
1
+ """Cmd/Ctrl-K command palette and global search for Streamlit apps."""
2
+
3
+ from ._component import command_palette, command_search
4
+ from ._schema import (
5
+ CommandSearchError,
6
+ action_item,
7
+ dataframe_items,
8
+ normalize_item,
9
+ normalize_items,
10
+ page_item,
11
+ )
12
+
13
+ __all__ = [
14
+ "CommandSearchError",
15
+ "action_item",
16
+ "command_palette",
17
+ "command_search",
18
+ "dataframe_items",
19
+ "normalize_item",
20
+ "normalize_items",
21
+ "page_item",
22
+ ]
23
+
24
+ __version__ = "0.1.0"
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from ._schema import (
7
+ CommandSearchError,
8
+ normalize_groups,
9
+ normalize_items,
10
+ normalize_search_fields,
11
+ )
12
+
13
+ _COMPONENT_NAME = "streamlit-command-palette.streamlit_command_palette"
14
+ _HTML = '<div class="scs-root" data-streamlit-command-palette></div>'
15
+ _renderer = None
16
+
17
+
18
+ def _get_component_renderer():
19
+ global _renderer
20
+ if _renderer is not None:
21
+ return _renderer
22
+
23
+ try:
24
+ import streamlit as st
25
+ except ModuleNotFoundError as exc:
26
+ raise RuntimeError(
27
+ "streamlit-command-palette requires Streamlit. "
28
+ "Install with `pip install streamlit-command-palette`."
29
+ ) from exc
30
+
31
+ components = getattr(getattr(st, "components", None), "v2", None)
32
+ if components is None or not hasattr(components, "component"):
33
+ raise RuntimeError(
34
+ "streamlit-command-palette requires Streamlit Custom Components v2 "
35
+ "(streamlit>=1.51.0)."
36
+ )
37
+
38
+ try:
39
+ _renderer = components.component(
40
+ _COMPONENT_NAME,
41
+ html=_HTML,
42
+ js="index.js",
43
+ isolate_styles=True,
44
+ )
45
+ except Exception:
46
+ # Source checkouts are often run with PYTHONPATH before the package is
47
+ # installed, so Streamlit has not discovered the component manifest yet.
48
+ # In that case, register the same bundled asset as inline JavaScript.
49
+ bundled_js = (
50
+ Path(__file__).parent / "frontend" / "build" / "index.js"
51
+ ).read_text(encoding="utf-8")
52
+ _renderer = components.component(
53
+ _COMPONENT_NAME,
54
+ html=_HTML,
55
+ js=bundled_js,
56
+ isolate_styles=True,
57
+ )
58
+ return _renderer
59
+
60
+
61
+ def command_search(
62
+ items,
63
+ placeholder: str = "Search...",
64
+ shortcut: str = "mod+k",
65
+ open: bool = False,
66
+ groups=None,
67
+ max_results: int = 10,
68
+ min_query_length: int = 0,
69
+ search_fields=None,
70
+ show_shortcut_hint: bool = True,
71
+ empty_state: str = "No results found",
72
+ key: str | None = None,
73
+ height: int | None = None,
74
+ theme: dict[str, Any] | None = None,
75
+ ):
76
+ """Render a Cmd/Ctrl-K command palette and return the selected item.
77
+
78
+ Returns a normalized item dictionary when the user selects an item in the
79
+ overlay. Returns ``None`` when nothing was selected on this rerun.
80
+ """
81
+
82
+ if not isinstance(placeholder, str):
83
+ raise CommandSearchError("placeholder must be a string")
84
+ if not isinstance(shortcut, str) or not shortcut.strip():
85
+ raise CommandSearchError("shortcut must be a non-empty string")
86
+ if not isinstance(empty_state, str):
87
+ raise CommandSearchError("empty_state must be a string")
88
+ if not isinstance(max_results, int) or max_results <= 0:
89
+ raise CommandSearchError("max_results must be a positive integer")
90
+ if not isinstance(min_query_length, int) or min_query_length < 0:
91
+ raise CommandSearchError("min_query_length must be an integer >= 0")
92
+ if height is not None and (not isinstance(height, int) or height < 0):
93
+ raise CommandSearchError("height must be a non-negative integer or None")
94
+ if theme is not None and not isinstance(theme, dict):
95
+ raise CommandSearchError("theme must be a mapping or None")
96
+
97
+ data = {
98
+ "items": normalize_items(items),
99
+ "placeholder": placeholder,
100
+ "shortcut": shortcut,
101
+ "open": bool(open),
102
+ "groups": normalize_groups(groups),
103
+ "maxResults": max_results,
104
+ "minQueryLength": min_query_length,
105
+ "searchFields": normalize_search_fields(search_fields),
106
+ "showShortcutHint": bool(show_shortcut_hint),
107
+ "emptyState": empty_state,
108
+ "theme": theme or {},
109
+ }
110
+
111
+ rendered_height = height
112
+ if rendered_height is None:
113
+ rendered_height = 44 if show_shortcut_hint else 0
114
+
115
+ result = _get_component_renderer()(
116
+ key=key,
117
+ data=data,
118
+ height=rendered_height,
119
+ on_selected_change=lambda: None,
120
+ )
121
+ return getattr(result, "selected", None)
122
+
123
+
124
+ command_palette = command_search
@@ -0,0 +1,397 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Mapping, Sequence
4
+ from dataclasses import asdict, is_dataclass
5
+ from datetime import date, datetime
6
+ from decimal import Decimal
7
+ from enum import Enum
8
+ from typing import Any
9
+
10
+
11
+ class CommandSearchError(ValueError):
12
+ """Raised when command palette input cannot be normalized safely."""
13
+
14
+
15
+ ITEM_FIELDS = (
16
+ "id",
17
+ "title",
18
+ "subtitle",
19
+ "type",
20
+ "group",
21
+ "target",
22
+ "url",
23
+ "icon",
24
+ "keywords",
25
+ "metadata",
26
+ "disabled",
27
+ )
28
+
29
+ DEFAULT_SEARCH_FIELDS = ("title", "subtitle", "keywords", "metadata")
30
+
31
+
32
+ def page_item(
33
+ id: str,
34
+ title: str,
35
+ *,
36
+ subtitle: str | None = None,
37
+ group: str | None = "Pages",
38
+ target: Any | None = None,
39
+ url: str | None = None,
40
+ icon: str | None = "page",
41
+ keywords: Sequence[Any] | None = None,
42
+ metadata: Mapping[str, Any] | None = None,
43
+ disabled: bool = False,
44
+ ) -> dict[str, Any]:
45
+ """Create a normalized page/navigation item."""
46
+
47
+ return normalize_item(
48
+ {
49
+ "id": id,
50
+ "title": title,
51
+ "subtitle": subtitle,
52
+ "type": "page",
53
+ "group": group,
54
+ "target": target if target is not None else url,
55
+ "url": url,
56
+ "icon": icon,
57
+ "keywords": keywords,
58
+ "metadata": metadata,
59
+ "disabled": disabled,
60
+ }
61
+ )
62
+
63
+
64
+ def action_item(
65
+ id: str,
66
+ title: str,
67
+ *,
68
+ subtitle: str | None = None,
69
+ group: str | None = "Actions",
70
+ target: Any | None = None,
71
+ icon: str | None = "action",
72
+ keywords: Sequence[Any] | None = None,
73
+ metadata: Mapping[str, Any] | None = None,
74
+ disabled: bool = False,
75
+ ) -> dict[str, Any]:
76
+ """Create a normalized action item.
77
+
78
+ The component returns the selected item to Python. Execute the action in
79
+ your Streamlit script by inspecting the returned item id or target.
80
+ """
81
+
82
+ return normalize_item(
83
+ {
84
+ "id": id,
85
+ "title": title,
86
+ "subtitle": subtitle,
87
+ "type": "action",
88
+ "group": group,
89
+ "target": target if target is not None else id,
90
+ "url": None,
91
+ "icon": icon,
92
+ "keywords": keywords,
93
+ "metadata": metadata,
94
+ "disabled": disabled,
95
+ }
96
+ )
97
+
98
+
99
+ def dataframe_items(
100
+ data: Any,
101
+ *,
102
+ id_field: str | None = None,
103
+ title_field: str | None = None,
104
+ subtitle_fields: str | Sequence[str] | None = None,
105
+ group: str | None = "Data",
106
+ target_field: str | None = None,
107
+ icon: str | None = "dataframe",
108
+ keywords_fields: Sequence[str] | None = None,
109
+ metadata_fields: Sequence[str] | None = None,
110
+ max_items: int | None = None,
111
+ id_prefix: str = "row",
112
+ disabled_field: str | None = None,
113
+ ) -> list[dict[str, Any]]:
114
+ """Convert DataFrame-like records into command palette items.
115
+
116
+ Accepts a pandas DataFrame, any object with ``to_dict(orient="records")``,
117
+ a mapping of columns to values, or an iterable of row mappings.
118
+ """
119
+
120
+ records = _records_from_data(data)
121
+ if max_items is not None:
122
+ if max_items < 0:
123
+ raise CommandSearchError("max_items must be greater than or equal to 0")
124
+ records = records[:max_items]
125
+
126
+ items: list[dict[str, Any]] = []
127
+ for index, record in enumerate(records):
128
+ if not isinstance(record, Mapping):
129
+ raise CommandSearchError("dataframe_items rows must be mappings")
130
+ row = {str(key): _json_safe(value) for key, value in record.items()}
131
+ inferred_title_field = title_field or _first_present(
132
+ row, ("title", "name", "label")
133
+ )
134
+ if inferred_title_field is None and row:
135
+ inferred_title_field = next(iter(row))
136
+ if inferred_title_field is None:
137
+ raise CommandSearchError("Cannot infer a title field from empty row data")
138
+
139
+ row_id = _field_value(row, id_field) if id_field else row.get("id")
140
+ item_id = str(row_id) if row_id not in (None, "") else f"{id_prefix}-{index}"
141
+ title = str(row.get(inferred_title_field, item_id))
142
+ subtitle = _subtitle_from_fields(row, subtitle_fields, inferred_title_field)
143
+ metadata_keys = (
144
+ list(metadata_fields) if metadata_fields is not None else list(row)
145
+ )
146
+ keyword_keys = (
147
+ list(keywords_fields) if keywords_fields is not None else list(row)
148
+ )
149
+ target = _field_value(row, target_field) if target_field else item_id
150
+ disabled = bool(_field_value(row, disabled_field)) if disabled_field else False
151
+
152
+ items.append(
153
+ normalize_item(
154
+ {
155
+ "id": item_id,
156
+ "title": title,
157
+ "subtitle": subtitle,
158
+ "type": "dataframe",
159
+ "group": group,
160
+ "target": target,
161
+ "url": None,
162
+ "icon": icon,
163
+ "keywords": [row[key] for key in keyword_keys if key in row],
164
+ "metadata": {key: row[key] for key in metadata_keys if key in row},
165
+ "disabled": disabled,
166
+ }
167
+ )
168
+ )
169
+ return items
170
+
171
+
172
+ def normalize_items(items: Iterable[Any]) -> list[dict[str, Any]]:
173
+ """Normalize and validate a collection of command palette items."""
174
+
175
+ if isinstance(items, (str, bytes)) or not isinstance(items, Iterable):
176
+ raise CommandSearchError("items must be an iterable of mappings")
177
+
178
+ normalized = [normalize_item(item, index=index) for index, item in enumerate(items)]
179
+ seen: set[str] = set()
180
+ duplicates: set[str] = set()
181
+ for item in normalized:
182
+ item_id = item["id"]
183
+ if item_id in seen:
184
+ duplicates.add(item_id)
185
+ seen.add(item_id)
186
+ if duplicates:
187
+ duplicate_list = ", ".join(sorted(duplicates))
188
+ raise CommandSearchError(
189
+ f"item ids must be unique; duplicates: {duplicate_list}"
190
+ )
191
+ return normalized
192
+
193
+
194
+ def normalize_item(item: Any, *, index: int | None = None) -> dict[str, Any]:
195
+ """Normalize and validate a single command palette item."""
196
+
197
+ raw = _mapping_from_item(item)
198
+ suffix = f" at index {index}" if index is not None else ""
199
+ item_id = raw.get("id")
200
+ title = raw.get("title")
201
+ if item_id in (None, ""):
202
+ raise CommandSearchError(f"item{suffix} is missing required field 'id'")
203
+ if title in (None, ""):
204
+ raise CommandSearchError(f"item{suffix} is missing required field 'title'")
205
+
206
+ return {
207
+ "id": str(item_id),
208
+ "title": str(title),
209
+ "subtitle": _optional_string(raw.get("subtitle")),
210
+ "type": str(raw.get("type") or "item"),
211
+ "group": _optional_string(raw.get("group")),
212
+ "target": _json_safe(raw.get("target")),
213
+ "url": _optional_string(raw.get("url")),
214
+ "icon": _optional_string(raw.get("icon")),
215
+ "keywords": _normalize_keywords(raw.get("keywords")),
216
+ "metadata": _normalize_metadata(raw.get("metadata")),
217
+ "disabled": bool(raw.get("disabled", False)),
218
+ }
219
+
220
+
221
+ def normalize_groups(groups: Any) -> list[dict[str, Any]] | None:
222
+ """Normalize group ordering/labels for the frontend."""
223
+
224
+ if groups is None:
225
+ return None
226
+ if isinstance(groups, Mapping):
227
+ output = []
228
+ for group_id, config in groups.items():
229
+ if isinstance(config, Mapping):
230
+ output.append(
231
+ {
232
+ "id": str(group_id),
233
+ "title": str(
234
+ config.get("title") or config.get("label") or group_id
235
+ ),
236
+ "icon": _optional_string(config.get("icon")),
237
+ }
238
+ )
239
+ else:
240
+ output.append({"id": str(group_id), "title": str(config), "icon": None})
241
+ return output
242
+ if isinstance(groups, (str, bytes)) or not isinstance(groups, Iterable):
243
+ raise CommandSearchError("groups must be a mapping or iterable")
244
+
245
+ output = []
246
+ for entry in groups:
247
+ if isinstance(entry, Mapping):
248
+ group_id = entry.get("id") or entry.get("group") or entry.get("title")
249
+ if group_id in (None, ""):
250
+ raise CommandSearchError("group entries must include id or title")
251
+ output.append(
252
+ {
253
+ "id": str(group_id),
254
+ "title": str(entry.get("title") or entry.get("label") or group_id),
255
+ "icon": _optional_string(entry.get("icon")),
256
+ }
257
+ )
258
+ else:
259
+ output.append({"id": str(entry), "title": str(entry), "icon": None})
260
+ return output
261
+
262
+
263
+ def normalize_search_fields(search_fields: Sequence[str] | None) -> list[str]:
264
+ if search_fields is None:
265
+ return list(DEFAULT_SEARCH_FIELDS)
266
+ if isinstance(search_fields, (str, bytes)) or not isinstance(
267
+ search_fields, Sequence
268
+ ):
269
+ raise CommandSearchError("search_fields must be a sequence of field names")
270
+ output = [str(field) for field in search_fields]
271
+ if not output:
272
+ raise CommandSearchError("search_fields cannot be empty")
273
+ return output
274
+
275
+
276
+ def _mapping_from_item(item: Any) -> Mapping[str, Any]:
277
+ if isinstance(item, Mapping):
278
+ return item
279
+ if is_dataclass(item) and not isinstance(item, type):
280
+ return asdict(item)
281
+ if hasattr(item, "_asdict"):
282
+ value = item._asdict()
283
+ if isinstance(value, Mapping):
284
+ return value
285
+ raise CommandSearchError("each item must be a mapping or dataclass")
286
+
287
+
288
+ def _records_from_data(data: Any) -> list[Mapping[str, Any]]:
289
+ if hasattr(data, "to_dict"):
290
+ try:
291
+ records = data.to_dict(orient="records")
292
+ except TypeError:
293
+ records = data.to_dict()
294
+ if isinstance(records, list):
295
+ return records
296
+ if isinstance(records, Mapping):
297
+ keys = list(records)
298
+ if not keys:
299
+ return []
300
+ length = len(records[keys[0]])
301
+ return [
302
+ {key: records[key][index] for key in keys}
303
+ for index in range(length)
304
+ ]
305
+ if isinstance(data, Mapping):
306
+ keys = list(data)
307
+ if not keys:
308
+ return []
309
+ first = data[keys[0]]
310
+ if isinstance(first, Sequence) and not isinstance(first, (str, bytes)):
311
+ return [
312
+ {key: data[key][index] for key in keys}
313
+ for index in range(len(first))
314
+ ]
315
+ return [data]
316
+ if isinstance(data, Iterable) and not isinstance(data, (str, bytes)):
317
+ return list(data)
318
+ raise CommandSearchError("data must be DataFrame-like or iterable row mappings")
319
+
320
+
321
+ def _json_safe(value: Any) -> Any:
322
+ if value is None or isinstance(value, (str, bool, int, float)):
323
+ return value
324
+ if isinstance(value, Decimal):
325
+ return float(value)
326
+ if isinstance(value, (datetime, date)):
327
+ return value.isoformat()
328
+ if isinstance(value, Enum):
329
+ return _json_safe(value.value)
330
+ if isinstance(value, Mapping):
331
+ return {str(key): _json_safe(inner) for key, inner in value.items()}
332
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
333
+ return [_json_safe(inner) for inner in value]
334
+ return str(value)
335
+
336
+
337
+ def _normalize_keywords(value: Any) -> list[str]:
338
+ if value in (None, ""):
339
+ return []
340
+ if isinstance(value, (str, bytes)):
341
+ return [str(value)]
342
+ if isinstance(value, Iterable):
343
+ return [str(_json_safe(item)) for item in value if item not in (None, "")]
344
+ return [str(_json_safe(value))]
345
+
346
+
347
+ def _normalize_metadata(value: Any) -> dict[str, Any]:
348
+ if value is None:
349
+ return {}
350
+ if not isinstance(value, Mapping):
351
+ raise CommandSearchError("metadata must be a mapping")
352
+ return {str(key): _json_safe(inner) for key, inner in value.items()}
353
+
354
+
355
+ def _optional_string(value: Any) -> str | None:
356
+ if value in (None, ""):
357
+ return None
358
+ return str(value)
359
+
360
+
361
+ def _first_present(row: Mapping[str, Any], candidates: Sequence[str]) -> str | None:
362
+ for candidate in candidates:
363
+ if candidate in row and row[candidate] not in (None, ""):
364
+ return candidate
365
+ return None
366
+
367
+
368
+ def _field_value(row: Mapping[str, Any], field: str | None) -> Any:
369
+ if field is None:
370
+ return None
371
+ return row.get(field)
372
+
373
+
374
+ def _subtitle_from_fields(
375
+ row: Mapping[str, Any],
376
+ subtitle_fields: str | Sequence[str] | None,
377
+ title_field: str,
378
+ ) -> str | None:
379
+ if isinstance(subtitle_fields, str):
380
+ value = row.get(subtitle_fields)
381
+ return None if value in (None, "") else str(value)
382
+ if subtitle_fields is not None:
383
+ pieces = [
384
+ str(row[field])
385
+ for field in subtitle_fields
386
+ if field in row and row[field] not in (None, "")
387
+ ]
388
+ return " - ".join(pieces) or None
389
+
390
+ pieces = []
391
+ for key, value in row.items():
392
+ if key == title_field or value in (None, ""):
393
+ continue
394
+ pieces.append(f"{key}: {value}")
395
+ if len(pieces) == 2:
396
+ break
397
+ return " - ".join(pieces) or None