streamlit-listview 1.0.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,542 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Iterable, Literal
4
+
5
+ import streamlit as st
6
+
7
+ from ._options import (
8
+ _id_key,
9
+ _validate_id,
10
+ map_selection,
11
+ normalize_options,
12
+ sort_items,
13
+ )
14
+
15
+ _component = st.components.v2.component(
16
+ "streamlit_listview.listview",
17
+ js="index-*.js",
18
+ css="index-*.css",
19
+ html='<div class="listview-root"></div>',
20
+ )
21
+
22
+ _SELECTION_MODES = ("single", "multi")
23
+ _LABEL_VISIBILITIES = ("visible", "hidden", "collapsed")
24
+ _SORT_MODES = ("items", "groups", "both")
25
+
26
+
27
+ def _is_real_int(value: Any) -> bool:
28
+ """True for genuine ints, rejecting bool (a subclass of int)."""
29
+ return isinstance(value, int) and not isinstance(value, bool)
30
+
31
+
32
+ def _is_int_at_least(value: Any, minimum: int) -> bool:
33
+ """True for a genuine int at or above ``minimum``.
34
+
35
+ THE int-parameter contract, shared by ``_validate_int`` and by ``width`` (whose
36
+ message differs only because it also accepts ``"stretch"``). Widening what
37
+ counts as an int — a numpy integer from ``df.shape[0]``, say — has to widen it
38
+ for every numeric parameter at once, which two copies of the predicate could
39
+ not guarantee.
40
+ """
41
+ return _is_real_int(value) and value >= minimum
42
+
43
+
44
+ def _validate_bool(name: str, value: Any) -> None:
45
+ """Reject a non-bool value for one flag parameter.
46
+
47
+ The flags differ only in their name, so one check serves them all — see
48
+ ``_validate_str`` on why the message text is spelled out rather than derived.
49
+ """
50
+ if not isinstance(value, bool):
51
+ raise ValueError(f"{name} must be a bool, got {type(value).__name__}.")
52
+
53
+
54
+ def _validate_str(name: str, value: Any, *, allow_none: bool) -> None:
55
+ """Reject a non-str value for one text parameter.
56
+
57
+ The text params differ only in their name and in whether None is a legal
58
+ "not shown", so one check serves them all. The message wording is part of the
59
+ public contract (the suite matches on it), hence the fixed " or None" suffix
60
+ rather than anything derived from the type.
61
+ """
62
+ if allow_none and value is None:
63
+ return
64
+ if not isinstance(value, str):
65
+ expected = "a str or None" if allow_none else "a str"
66
+ raise ValueError(f"{name} must be {expected}, got {type(value).__name__}.")
67
+
68
+
69
+ def _validate_int(name: str, value: Any, *, minimum: int, allow_none: bool) -> None:
70
+ """Reject a non-int or out-of-range value for one numeric parameter.
71
+
72
+ The numeric params differ only in their name, their floor and whether None
73
+ means "unset", so one check serves them all — see `_validate_str` on why the
74
+ message text is spelled out rather than derived.
75
+ """
76
+ if allow_none and value is None:
77
+ return
78
+ if not _is_int_at_least(value, minimum):
79
+ expected = (
80
+ f"None or an int >= {minimum}" if allow_none else f"an int >= {minimum}"
81
+ )
82
+ raise ValueError(f"{name} must be {expected}, got {value!r}.")
83
+
84
+
85
+ def _validate_params(
86
+ *,
87
+ label: Any,
88
+ help: Any,
89
+ placeholder: Any,
90
+ search_placeholder: Any,
91
+ selection_mode: str,
92
+ label_visibility: str,
93
+ width: Any,
94
+ height: Any,
95
+ item_height: Any,
96
+ content_font_size: Any,
97
+ max_selections: Any,
98
+ enable_search: Any,
99
+ pin_search: Any,
100
+ collapsible_groups: Any,
101
+ accept_new_options: Any,
102
+ show_grid_lines: Any,
103
+ disabled: Any,
104
+ format_func: Any,
105
+ on_change: Any,
106
+ select_all: Any,
107
+ sort: Any,
108
+ sort_ascending: Any,
109
+ collapsed_groups: Any,
110
+ ) -> None:
111
+ """Validate scalar parameters per spec §7. Raises ValueError."""
112
+ # The four text params are handed to the frontend verbatim — `label` and
113
+ # `help` as Markdown source, the two placeholders as <input>/empty-state text.
114
+ # A non-str `label` reaches escapeLabelSource, which calls .replace() on it and
115
+ # throws during React render; like the collapsed_groups shape below, that
116
+ # crashes the whole widget with nothing to show for it on the Python side (no
117
+ # traceback, just an empty component). So the type is checked here.
118
+ #
119
+ # None-ness deliberately mirrors the signature defaults *and* the frontend's
120
+ # ListviewData contract: `label: string` and `search_placeholder: string` are
121
+ # non-nullable there, while `help: string | null` and
122
+ # `placeholder: string | null` use null to mean "not shown". Only wrong types
123
+ # are rejected; which params accept None is unchanged.
124
+ _validate_str("label", label, allow_none=False)
125
+ _validate_str("help", help, allow_none=True)
126
+ _validate_str("placeholder", placeholder, allow_none=True)
127
+ _validate_str("search_placeholder", search_placeholder, allow_none=False)
128
+
129
+ if selection_mode not in _SELECTION_MODES:
130
+ raise ValueError(
131
+ f"selection_mode must be one of {_SELECTION_MODES!r}, got {selection_mode!r}."
132
+ )
133
+
134
+ if label_visibility not in _LABEL_VISIBILITIES:
135
+ raise ValueError(
136
+ "label_visibility must be one of "
137
+ f"{_LABEL_VISIBILITIES!r}, got {label_visibility!r}."
138
+ )
139
+
140
+ if width != "stretch" and not _is_int_at_least(width, 1):
141
+ raise ValueError(
142
+ f'width must be "stretch" or an int >= 1, got {width!r}.'
143
+ )
144
+
145
+ _validate_int("height", height, minimum=100, allow_none=False)
146
+ _validate_int("item_height", item_height, minimum=1, allow_none=True)
147
+ _validate_int("content_font_size", content_font_size, minimum=1, allow_none=True)
148
+ _validate_int("max_selections", max_selections, minimum=1, allow_none=True)
149
+
150
+ if (
151
+ max_selections is not None
152
+ and selection_mode == "single"
153
+ and max_selections != 1
154
+ ):
155
+ raise ValueError(
156
+ 'max_selections must be None or 1 when selection_mode="single", '
157
+ f"got {max_selections!r}."
158
+ )
159
+
160
+ # The eight flag params are forwarded to the frontend verbatim and judged by
161
+ # JS truthiness there, so a non-bool never fails loudly on its own:
162
+ # enable_search="no" ENABLES the search box, accept_new_options="false"
163
+ # ENABLES adding, and a truthy non-JSON-serializable value blanks the whole
164
+ # widget (Streamlit's serializer swallows the TypeError — see
165
+ # collapsed_groups below). The type checks run before the flag cross-checks
166
+ # so a wrong type is named as such, never reported as a spurious
167
+ # combination error.
168
+ _validate_bool("enable_search", enable_search)
169
+ _validate_bool("pin_search", pin_search)
170
+ _validate_bool("collapsible_groups", collapsible_groups)
171
+ _validate_bool("accept_new_options", accept_new_options)
172
+ _validate_bool("show_grid_lines", show_grid_lines)
173
+ _validate_bool("disabled", disabled)
174
+ _validate_bool("select_all", select_all)
175
+ _validate_bool("sort_ascending", sort_ascending)
176
+
177
+ if pin_search and not enable_search:
178
+ raise ValueError("pin_search=True requires enable_search=True.")
179
+
180
+ if selection_mode == "single" and select_all:
181
+ raise ValueError('select_all=True not allowed for selection_mode="single"')
182
+
183
+ if sort is not None and sort not in _SORT_MODES:
184
+ raise ValueError(
185
+ f"sort must be one of {_SORT_MODES!r} or None, got {sort!r}."
186
+ )
187
+
188
+ # collapsed_groups: None | "all" | list[str]. Validate the shape here so a
189
+ # common mistake (passing a bare group name instead of a one-element list,
190
+ # e.g. collapsed_groups="Germany") is rejected at the boundary rather than
191
+ # forwarded to the frontend, where it would reach `collapsedGroups.filter`
192
+ # and throw, crashing the whole widget at render time.
193
+ if collapsed_groups is not None and collapsed_groups != "all":
194
+ if not (
195
+ isinstance(collapsed_groups, list)
196
+ and all(isinstance(name, str) for name in collapsed_groups)
197
+ ):
198
+ raise ValueError(
199
+ 'collapsed_groups must be None, "all", or a list of group-name '
200
+ f"strings, got {collapsed_groups!r}."
201
+ )
202
+
203
+ if format_func is not None and not callable(format_func):
204
+ raise ValueError("format_func must be callable or None.")
205
+
206
+ if on_change is not None and not callable(on_change):
207
+ raise ValueError("on_change must be callable or None.")
208
+
209
+
210
+ def _resolve_default_ids(
211
+ default: Any,
212
+ selection_mode: str,
213
+ id_to_original: dict[Any, Any],
214
+ accept_new_options: bool,
215
+ ) -> list[Any]:
216
+ """Resolve the `default` argument to a list of option ids.
217
+
218
+ single: a scalar (non-list) default d -> [d] (or [] when None).
219
+ multi: a list default -> that list (or [] when None).
220
+ Shape mismatches raise ValueError. Every id must pass the same boundary gate
221
+ as an option id (``_validate_id``: exactly str or int, bool rejected, ints
222
+ within the JS safe range) — a default is seeded straight into the frontend
223
+ selection, so it crosses the same JSON/``String(id)`` boundary. When
224
+ accept_new_options is False, every resolved id must additionally name a row
225
+ of the options, membership judged by ``_id_key`` rendered row identity.
226
+ """
227
+ if selection_mode == "single":
228
+ if isinstance(default, (list, tuple, set)):
229
+ raise ValueError(
230
+ 'default must not be a list when selection_mode="single"; '
231
+ f"got {default!r}."
232
+ )
233
+ default_ids: list[Any] = [] if default is None else [default]
234
+ else: # multi
235
+ if default is None:
236
+ default_ids = []
237
+ elif isinstance(default, list):
238
+ default_ids = list(default)
239
+ else:
240
+ raise ValueError(
241
+ 'default must be a list when selection_mode="multi"; '
242
+ f"got {default!r}."
243
+ )
244
+
245
+ # The unhashable check runs BEFORE the shared gate so the natural way to hit
246
+ # it — round-tripping what listview() returned (default=OPTS[0], where
247
+ # OPTS[0] is a dict option) — gets this targeted hint instead of the gate's
248
+ # generic "must be a str or int". Then every id goes through the same
249
+ # boundary gate as an option id, regardless of accept_new_options: an
250
+ # unknown-but-unrepresentable id is not a "new option", it is an id the
251
+ # JSON/String(id) boundary cannot carry (a bool desyncs the UI from the
252
+ # value, a Decimal blanks the widget, 2**53 + 1 rounds — see _validate_id).
253
+ for did in default_ids:
254
+ try:
255
+ hash(did)
256
+ except TypeError as exc:
257
+ raise ValueError(
258
+ f"default id is unhashable: {did!r}. Pass the option's id, not "
259
+ "the option itself — a dict option comes back whole in the "
260
+ "selection, but only its 'id' can seed the default."
261
+ ) from exc
262
+ _validate_id(did, kind="default")
263
+
264
+ # De-duplicate while preserving order, first occurrence winning: a repeated
265
+ # default id is a single selection, so it must not inflate the max_selections
266
+ # count (checked by the caller) nor seed a duplicate id into the frontend
267
+ # selection.
268
+ #
269
+ # Keyed by _id_key — the rendered row identity — and NOT by Python equality,
270
+ # which is a weaker notion of "the same row" than the frontend's: with
271
+ # accept_new_options a default may name one row two ways (`["1", 1]`), and the
272
+ # frontend renders and counts that as the one row it is. Keying by equality let
273
+ # the pair through as two entries, so `max_selections=1` rejected a default
274
+ # that selects a single row, and reconciliation had to collapse a duplicate the
275
+ # producer should never have emitted. First-wins matches the survivor the
276
+ # frontend keeps. The same collapse holds with accept_new_options off: the
277
+ # membership check below judges by this same key, so a default spelling one
278
+ # option's row two ways (`["1", 1]` against the int option 1) is a single
279
+ # valid selection there too, not two entries.
280
+ by_key: dict[str, Any] = {}
281
+ for did in default_ids:
282
+ by_key.setdefault(_id_key(did), did)
283
+ default_ids = list(by_key.values())
284
+
285
+ if default_ids:
286
+ # Membership is a row-identity question, so it is asked by _id_key like
287
+ # every other one: a default spelling a row differently than its option
288
+ # ("1" for the int option 1) names a row the frontend renders, so it is
289
+ # accepted — while raw Python ==, besides refusing that spelling, also
290
+ # admitted ids that merely compare equal without rendering as any row
291
+ # (Decimal('7') == 7 seeded an id the payload cannot even carry).
292
+ # Gated on default_ids: option_by_key is a full pass over the options,
293
+ # which the common default=None call must not pay on every rerun.
294
+ option_by_key = {_id_key(item_id): item_id for item_id in id_to_original}
295
+ if not accept_new_options:
296
+ for did in default_ids:
297
+ if _id_key(did) not in option_by_key:
298
+ raise ValueError(
299
+ f"default id {did!r} is not present in options "
300
+ "(set accept_new_options=True to allow unknown defaults)."
301
+ )
302
+ # Ship each matched default in the OPTION's own spelling, not the
303
+ # caller's. The seed is both the mount display and the V2 `default=`
304
+ # state, and the frontend canonicalizes the selection to the option's
305
+ # spelling on its first render — so a default that named the row
306
+ # differently ("1" for the int option 1) was corrected by a write-back
307
+ # that is indistinguishable from a click: an extra rerun and a spurious
308
+ # on_change at mount, with no user action (and on EVERY remount of an
309
+ # unkeyed instance). Canonicalizing here keeps the seed byte-equal to
310
+ # what the frontend would commit, so mount stays quiet. An id with no
311
+ # matching row (accept_new_options) is kept as given.
312
+ default_ids = [option_by_key.get(_id_key(did), did) for did in default_ids]
313
+
314
+ return default_ids
315
+
316
+
317
+ def _persisted_selection(key: str | None) -> list | None:
318
+ """The selection persisted in ``st.session_state[key]``, or None.
319
+
320
+ A keyed V2 component's element id drops the container, so moving the widget
321
+ between ``st.sidebar`` and the main body keeps the Python-side widget state
322
+ while the frontend remounts from scratch. The remounted frontend seeds from
323
+ this value (shipped as ``data["state_selection"]``) when it is present,
324
+ falling back to ``default_ids`` — without the channel, such a move re-seeded
325
+ the rows from ``default`` while ``listview()`` kept returning the persisted
326
+ selection, and the next click silently overwrote it.
327
+
328
+ Best-effort by design: anything short of a well-shaped state degrades to
329
+ None ("nothing persisted; seed from default_ids"). That covers no ``key``,
330
+ a first run where the widget state does not exist yet (KeyError), and an
331
+ environment with no Streamlit runtime at all — the unit suite imports real
332
+ streamlit without a script run context, where the read hits the bare-mode
333
+ mock session state and raises KeyError too. The shape check (a dict with a
334
+ list under "selection" whose every element passes ``_validate_id``) guards
335
+ against a caller having overwritten the key with arbitrary user state — the
336
+ key is user-writable, and an app that pre-seeds it (or assigns the selection
337
+ from data) can put a Decimal, a date or a numpy int in the list. Those ids
338
+ cross the same JSON/``String(id)`` boundary as an option id, and an
339
+ unrepresentable one blanks the widget with no Python error (see
340
+ ``_validate_id``), so the list is trusted whole or not at all: filtering
341
+ single elements would be a fourth way for the rendered rows to disagree
342
+ with the returned value.
343
+ """
344
+ if key is None:
345
+ return None
346
+ try:
347
+ state = st.session_state[key]
348
+ except Exception:
349
+ # A missing key raises KeyError; session-state access can also raise
350
+ # StreamlitAPIException for keys it refuses to serve. Every failure
351
+ # means the same thing here: nothing persisted.
352
+ return None
353
+ if not isinstance(state, dict):
354
+ return None
355
+ selection = state.get("selection")
356
+ if not isinstance(selection, list):
357
+ return None
358
+ try:
359
+ for item_id in selection:
360
+ _validate_id(item_id, kind="persisted")
361
+ except ValueError:
362
+ return None
363
+ # A copy: the V2 presenter hands out a live write-through view of the
364
+ # widget state, and the payload must not alias it.
365
+ return list(selection)
366
+
367
+
368
+ def listview(
369
+ label: str,
370
+ options: Iterable[Any] | None = None,
371
+ *,
372
+ selection_mode: Literal["single", "multi"] = "single",
373
+ default: Any | list[Any] | None = None,
374
+ format_func: Callable[[Any], str] | None = None,
375
+ enable_search: bool = False,
376
+ pin_search: bool = False,
377
+ search_placeholder: str = "Search",
378
+ collapsible_groups: bool = False,
379
+ collapsed_groups: list[str] | Literal["all"] | None = None,
380
+ sort: Literal["items", "groups", "both"] | None = None,
381
+ sort_ascending: bool = True,
382
+ accept_new_options: bool = False,
383
+ max_selections: int | None = None,
384
+ select_all: bool = False,
385
+ placeholder: str | None = None,
386
+ height: int = 300,
387
+ item_height: int | None = None,
388
+ content_font_size: int | None = None,
389
+ width: Literal["stretch"] | int = "stretch",
390
+ show_grid_lines: bool = True,
391
+ help: str | None = None,
392
+ disabled: bool = False,
393
+ label_visibility: Literal["visible", "hidden", "collapsed"] = "visible",
394
+ on_change: Callable[..., None] | None = None,
395
+ args: tuple | None = None,
396
+ kwargs: dict | None = None,
397
+ key: str | None = None,
398
+ ) -> dict | str | int | list | None:
399
+ """Display a scrollable, selectable listview widget.
400
+
401
+ See the design spec for full behavior. `key` is strongly recommended for
402
+ any dynamic app so that frontend-local UI state and the selection survive
403
+ parameter changes (an unkeyed instance is remounted and reset).
404
+
405
+ `item_height` is a *minimum* row height in pixels (CSS ``min-height``), not a
406
+ fixed height: a row always grows to fit its content, so labels are never
407
+ clipped at any value. ``None`` (the default) lets each row size to its
408
+ content. Because it is only a floor, values below a row's natural one-line
409
+ height have no visible effect — the content height wins — so it is mainly
410
+ useful to make rows *taller* (e.g. roomier touch targets). The natural floor
411
+ is computed by the browser at layout time and tracks the active theme's font
412
+ size, which is why no fixed minimum is enforced here beyond ``>= 1``.
413
+
414
+ ``content_font_size`` sets the font size (px) of the list *content* — the
415
+ option rows and the group headers — which in turn lowers each row's natural
416
+ height for a more compact list. ``None`` (the default) keeps the theme size
417
+ (``base × 0.875``, theme-tracked). It does **not** change the widget label,
418
+ search box, add-option input, or placeholder (those stay at the native
419
+ theme size), and it composes with ``item_height``: the font sets the natural
420
+ height, while ``item_height`` remains a floor that wins when larger.
421
+
422
+ ``show_grid_lines`` (default ``True``) draws divider lines between option
423
+ rows; set it ``False`` for a more compact, line-free list (group headers
424
+ keep their own separator).
425
+
426
+ ``format_func`` maps an option to its display label and receives **the option
427
+ as passed** — the dict for dict options, the scalar itself for ``str``/``int``
428
+ options — so a label can be built from fields the option carries (e.g.
429
+ ``lambda o: f"{o['text']} ({o['type']})"``). It is consulted only for options
430
+ without a *usable* ``label``; an explicit one wins outright. Mind that
431
+ ``{"label": None}`` and ``{"label": nan}`` count as unusable and do reach the
432
+ formatter — that is the ``df.to_dict("records")`` NULL-cell shape. ``None``
433
+ (the default) means no formatter: the label is ``str(id)``. The option handed
434
+ over is your own object, not a copy; treat it as read-only.
435
+ """
436
+ _validate_params(
437
+ label=label,
438
+ help=help,
439
+ placeholder=placeholder,
440
+ search_placeholder=search_placeholder,
441
+ selection_mode=selection_mode,
442
+ label_visibility=label_visibility,
443
+ width=width,
444
+ height=height,
445
+ item_height=item_height,
446
+ content_font_size=content_font_size,
447
+ max_selections=max_selections,
448
+ enable_search=enable_search,
449
+ pin_search=pin_search,
450
+ collapsible_groups=collapsible_groups,
451
+ accept_new_options=accept_new_options,
452
+ show_grid_lines=show_grid_lines,
453
+ disabled=disabled,
454
+ format_func=format_func,
455
+ on_change=on_change,
456
+ select_all=select_all,
457
+ sort=sort,
458
+ sort_ascending=sort_ascending,
459
+ collapsed_groups=collapsed_groups,
460
+ )
461
+
462
+ items, id_to_original = normalize_options(options, format_func)
463
+ items = sort_items(items, sort, sort_ascending)
464
+
465
+ # No options and no way to add any -> nothing is selectable: default the
466
+ # placeholder prompt to "No options to select" and disable the widget
467
+ # (matching st.multiselect). An explicit placeholder is kept as-is.
468
+ if not items and not accept_new_options:
469
+ disabled = True
470
+ if placeholder is None:
471
+ placeholder = "No options to select"
472
+
473
+ default_ids = _resolve_default_ids(
474
+ default, selection_mode, id_to_original, accept_new_options
475
+ )
476
+
477
+ if (
478
+ selection_mode == "multi"
479
+ and max_selections is not None
480
+ and len(default_ids) > max_selections
481
+ ):
482
+ raise ValueError(
483
+ f"default has {len(default_ids)} options but max_selections is "
484
+ f"{max_selections}; reduce the default or raise max_selections."
485
+ )
486
+
487
+ data = {
488
+ "label": label,
489
+ "help": help,
490
+ "items": items,
491
+ "selection_mode": selection_mode,
492
+ "default_ids": default_ids,
493
+ "state_selection": _persisted_selection(key),
494
+ "enable_search": enable_search,
495
+ "pin_search": pin_search,
496
+ "search_placeholder": search_placeholder,
497
+ "collapsible_groups": collapsible_groups,
498
+ "collapsed_groups": collapsed_groups,
499
+ "accept_new_options": accept_new_options,
500
+ "max_selections": max_selections,
501
+ "placeholder": placeholder,
502
+ "height": height,
503
+ "item_height": item_height,
504
+ "content_font_size": content_font_size,
505
+ "width": width,
506
+ "show_grid_lines": show_grid_lines,
507
+ "select_all": select_all,
508
+ "disabled": disabled,
509
+ "label_visibility": label_visibility,
510
+ }
511
+
512
+ if on_change is None:
513
+ on_selection_change = lambda: None # noqa: E731
514
+ else:
515
+ cb_args = args or ()
516
+ cb_kwargs = kwargs or {}
517
+
518
+ def on_selection_change() -> None:
519
+ on_change(*cb_args, **cb_kwargs)
520
+
521
+ result = _component(
522
+ data=data,
523
+ default={"selection": default_ids},
524
+ on_selection_change=on_selection_change,
525
+ key=key,
526
+ )
527
+
528
+ selected_ids = result.get("selection")
529
+ if not isinstance(selected_ids, list):
530
+ # Guard against a tampered/buggy frontend returning a non-list (the V2
531
+ # state is typed Id[] in TypeScript, but that is not a runtime
532
+ # guarantee). Treat anything that is not a list as "no selection"
533
+ # rather than iterating it — a returned string would otherwise be
534
+ # split into per-character ids.
535
+ selected_ids = []
536
+ return map_selection(
537
+ selected_ids,
538
+ id_to_original,
539
+ selection_mode,
540
+ accept_new_options,
541
+ max_selections,
542
+ )