moops 0.12.0__tar.gz → 0.12.2__tar.gz
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.
- {moops-0.12.0 → moops-0.12.2}/PKG-INFO +5 -2
- {moops-0.12.0 → moops-0.12.2}/README.md +2 -0
- {moops-0.12.0 → moops-0.12.2}/pyproject.toml +6 -2
- {moops-0.12.0 → moops-0.12.2}/src/moops/_control_mirroring.py +5 -26
- {moops-0.12.0 → moops-0.12.2}/src/moops/_custom_element.py +3 -12
- {moops-0.12.0 → moops-0.12.2}/src/moops/_embed.py +30 -7
- {moops-0.12.0 → moops-0.12.2}/src/moops/_list_options.py +66 -57
- {moops-0.12.0 → moops-0.12.2}/src/moops/_options.py +81 -118
- {moops-0.12.0 → moops-0.12.2}/src/moops/_query_params.py +5 -12
- {moops-0.12.0 → moops-0.12.2}/src/moops/_terminal_graphics.py +9 -19
- {moops-0.12.0 → moops-0.12.2}/src/moops/_ui_workarounds.py +0 -1
- {moops-0.12.0 → moops-0.12.2}/src/moops/_value_resolution.py +29 -2
- {moops-0.12.0 → moops-0.12.2}/src/moops/group.py +41 -3
- {moops-0.12.0 → moops-0.12.2}/src/moops/interface.py +65 -13
- {moops-0.12.0 → moops-0.12.2}/src/moops/__init__.py +0 -0
- {moops-0.12.0 → moops-0.12.2}/src/moops/_choice_options.py +0 -0
- {moops-0.12.0 → moops-0.12.2}/src/moops/_input_map.py +0 -0
- {moops-0.12.0 → moops-0.12.2}/src/moops/_interface_utils.py +0 -0
- {moops-0.12.0 → moops-0.12.2}/src/moops/_list_controls.py +0 -0
- {moops-0.12.0 → moops-0.12.2}/src/moops/_marimo_controls.py +0 -0
- {moops-0.12.0 → moops-0.12.2}/src/moops/_markdown.py +0 -0
- {moops-0.12.0 → moops-0.12.2}/src/moops/_naming.py +0 -0
- {moops-0.12.0 → moops-0.12.2}/src/moops/_parse.py +0 -0
- {moops-0.12.0 → moops-0.12.2}/src/moops/_preset_state.py +0 -0
- {moops-0.12.0 → moops-0.12.2}/src/moops/_presets_ui.py +0 -0
- {moops-0.12.0 → moops-0.12.2}/src/moops/_run.py +0 -0
- {moops-0.12.0 → moops-0.12.2}/src/moops/_run_button.py +0 -0
- {moops-0.12.0 → moops-0.12.2}/src/moops/_subgroup_registry.py +0 -0
- {moops-0.12.0 → moops-0.12.2}/src/moops/_text_wrap.py +0 -0
- {moops-0.12.0 → moops-0.12.2}/src/moops/_variant.py +0 -0
- {moops-0.12.0 → moops-0.12.2}/src/moops/presets.py +0 -0
- {moops-0.12.0 → moops-0.12.2}/src/moops/workarounds.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: moops
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.2
|
|
4
4
|
Summary: Write Marimo notebooks that also work as CLI scripts, with unified UI controls
|
|
5
5
|
Keywords: marimo,notebooks,cli,testing
|
|
6
6
|
Author: Yair Chuchem
|
|
@@ -13,11 +13,12 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
15
15
|
Requires-Dist: marimo>=0.23.1
|
|
16
|
-
Requires-Dist: hypothesis
|
|
16
|
+
Requires-Dist: hypothesis ; extra == 'test'
|
|
17
17
|
Requires-Python: >=3.10
|
|
18
18
|
Project-URL: Homepage, https://github.com/yairchu/moops
|
|
19
19
|
Project-URL: Repository, https://github.com/yairchu/moops
|
|
20
20
|
Project-URL: Issues, https://github.com/yairchu/moops/issues
|
|
21
|
+
Provides-Extra: test
|
|
21
22
|
Description-Content-Type: text/markdown
|
|
22
23
|
|
|
23
24
|
# moops
|
|
@@ -174,6 +175,8 @@ x_range = args.custom(
|
|
|
174
175
|
|
|
175
176
|
`moops.interface_of` returns the notebook's `Interface`, from which `.strategy()` generates a [Hypothesis](https://hypothesis.readthedocs.io/) strategy that produces valid `moops.run` kwargs by introspecting the notebook's interface — dropdowns yield their allowed keys, switches yield booleans, and text fields yield arbitrary strings.
|
|
176
177
|
|
|
178
|
+
Hypothesis is an optional dependency, since it is only needed for `.strategy()`; install it with `pip install moops[test]` (or just `pip install hypothesis`).
|
|
179
|
+
|
|
177
180
|
```python
|
|
178
181
|
from examples.composition import name_casing
|
|
179
182
|
|
|
@@ -152,6 +152,8 @@ x_range = args.custom(
|
|
|
152
152
|
|
|
153
153
|
`moops.interface_of` returns the notebook's `Interface`, from which `.strategy()` generates a [Hypothesis](https://hypothesis.readthedocs.io/) strategy that produces valid `moops.run` kwargs by introspecting the notebook's interface — dropdowns yield their allowed keys, switches yield booleans, and text fields yield arbitrary strings.
|
|
154
154
|
|
|
155
|
+
Hypothesis is an optional dependency, since it is only needed for `.strategy()`; install it with `pip install moops[test]` (or just `pip install hypothesis`).
|
|
156
|
+
|
|
155
157
|
```python
|
|
156
158
|
from examples.composition import name_casing
|
|
157
159
|
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "moops"
|
|
7
|
-
version = "0.12.
|
|
7
|
+
version = "0.12.2"
|
|
8
8
|
description = "Write Marimo notebooks that also work as CLI scripts, with unified UI controls"
|
|
9
9
|
license = "MIT"
|
|
10
10
|
readme = "README.md"
|
|
@@ -24,9 +24,12 @@ classifiers = [
|
|
|
24
24
|
]
|
|
25
25
|
dependencies = [
|
|
26
26
|
"marimo>=0.23.1",
|
|
27
|
-
"hypothesis",
|
|
28
27
|
]
|
|
29
28
|
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
# Interface.strategy() builds a Hypothesis strategy; only needed for testing.
|
|
31
|
+
test = ["hypothesis"]
|
|
32
|
+
|
|
30
33
|
[project.urls]
|
|
31
34
|
Homepage = "https://github.com/yairchu/moops"
|
|
32
35
|
Repository = "https://github.com/yairchu/moops"
|
|
@@ -34,6 +37,7 @@ Issues = "https://github.com/yairchu/moops/issues"
|
|
|
34
37
|
|
|
35
38
|
[dependency-groups]
|
|
36
39
|
dev = [
|
|
40
|
+
"hypothesis",
|
|
37
41
|
"ruff>=0.1.0",
|
|
38
42
|
"pyright>=1.1.409",
|
|
39
43
|
"pytest>=9.0.3",
|
|
@@ -80,7 +80,7 @@ class VariantAwareDictionary(mo.ui.dictionary):
|
|
|
80
80
|
result: dict[str, typing.Any] = {}
|
|
81
81
|
rendered_variant_groups: set[tuple[str, str]] = set()
|
|
82
82
|
for name, element in self.elements.items():
|
|
83
|
-
sub_iface =
|
|
83
|
+
sub_iface = interface.attached_interface(element)
|
|
84
84
|
if sub_iface is None or sub_iface.variant_ctx.group_prefix is None:
|
|
85
85
|
result[name] = element
|
|
86
86
|
continue
|
|
@@ -116,7 +116,7 @@ def _display_element(element: typing.Any) -> typing.Any:
|
|
|
116
116
|
visible = typing.cast(dict[str, typing.Any], visible_elements())
|
|
117
117
|
return mo.vstack([_display_element(child) for child in visible.values()])
|
|
118
118
|
elements = getattr(element, "elements", None)
|
|
119
|
-
if isinstance(elements, dict) and
|
|
119
|
+
if isinstance(elements, dict) and interface.attached_interface(element) is not None:
|
|
120
120
|
typed_elements = typing.cast(dict[str, typing.Any], elements)
|
|
121
121
|
return mo.vstack([_display_element(child) for child in typed_elements.values()])
|
|
122
122
|
return element
|
|
@@ -129,12 +129,12 @@ def _active_variant_element(
|
|
|
129
129
|
) -> tuple[str, typing.Any] | None:
|
|
130
130
|
selector_option = variant_iface.variant_ctx.selector_option
|
|
131
131
|
variant_group_prefix = variant_iface.variant_ctx.group_prefix
|
|
132
|
-
selected =
|
|
132
|
+
selected = interface.selected_value_for_option(root_iface, selector_option)
|
|
133
133
|
if selected is None:
|
|
134
134
|
return None
|
|
135
135
|
selected_key = _variant.key_text(selected)
|
|
136
136
|
for name, element in elements.items():
|
|
137
|
-
sub_iface =
|
|
137
|
+
sub_iface = interface.attached_interface(element)
|
|
138
138
|
if (
|
|
139
139
|
sub_iface is not None
|
|
140
140
|
and sub_iface.variant_ctx.selector_option == selector_option
|
|
@@ -145,16 +145,6 @@ def _active_variant_element(
|
|
|
145
145
|
return None
|
|
146
146
|
|
|
147
147
|
|
|
148
|
-
def _selected_value_for_option(
|
|
149
|
-
iface: interface.Interface, selector_option: str | None
|
|
150
|
-
) -> typing.Any:
|
|
151
|
-
return interface.selected_value_for_option(iface, selector_option)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def _attached_interface(ctrl: typing.Any) -> interface.Interface | None:
|
|
155
|
-
return interface.attached_interface(ctrl)
|
|
156
|
-
|
|
157
|
-
|
|
158
148
|
def _apply_mirrored_variant_ctx(
|
|
159
149
|
source: interface.Interface,
|
|
160
150
|
child_group: typing.Any,
|
|
@@ -195,20 +185,9 @@ def _create_control(
|
|
|
195
185
|
group: typing.Any,
|
|
196
186
|
iface: interface.Interface,
|
|
197
187
|
input_control: _options.InputControl,
|
|
198
|
-
) -> typing.Any:
|
|
199
|
-
return _create_from_input_control(
|
|
200
|
-
group,
|
|
201
|
-
input_control,
|
|
202
|
-
_interface_utils.unprefixed_option(iface, input_control.option),
|
|
203
|
-
)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def _create_from_input_control(
|
|
207
|
-
group: typing.Any,
|
|
208
|
-
input_control: _options.InputControl,
|
|
209
|
-
display_option: str,
|
|
210
188
|
) -> typing.Any:
|
|
211
189
|
"""Create a marimo element from an existing InputControl."""
|
|
190
|
+
display_option = _interface_utils.unprefixed_option(iface, input_control.option)
|
|
212
191
|
opt = group._make_opt(label=None, option=display_option)
|
|
213
192
|
cloned = input_control.with_option(opt.option)
|
|
214
193
|
return group._register_control(opt, cloned, cloned.help_text, None)
|
|
@@ -6,19 +6,10 @@ from marimo._plugins.ui._core.ui_element import UIElement
|
|
|
6
6
|
|
|
7
7
|
from . import _ui_workarounds
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
class CustomValueSource(typing.Protocol):
|
|
11
|
-
@property
|
|
12
|
-
def value(self) -> typing.Any: ...
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
CustomValueFn = typing.Callable[[CustomValueSource, CustomValueSource], typing.Any]
|
|
9
|
+
CustomValueFn = typing.Callable[[typing.Any, typing.Any], typing.Any]
|
|
16
10
|
|
|
17
11
|
|
|
18
|
-
def
|
|
19
|
-
component: CustomValueSource, fallback: CustomValueSource
|
|
20
|
-
) -> typing.Any:
|
|
21
|
-
del fallback
|
|
12
|
+
def _default_value_fn(component: typing.Any, _fallback: typing.Any) -> typing.Any:
|
|
22
13
|
return component.value
|
|
23
14
|
|
|
24
15
|
|
|
@@ -46,7 +37,7 @@ class CustomElement(UIElement[typing.Any, typing.Any]):
|
|
|
46
37
|
raise TypeError("custom controls must build a marimo UIElement")
|
|
47
38
|
self._component: UIElement[typing.Any, typing.Any] = component
|
|
48
39
|
self._fallback = fallback
|
|
49
|
-
self._value_fn = value_fn or
|
|
40
|
+
self._value_fn = value_fn or _default_value_fn
|
|
50
41
|
# Deliberately skip super().__init__(): we reuse the wrapped component's
|
|
51
42
|
# identity so marimo's reactive DAG treats this as the same element.
|
|
52
43
|
# Calling super().__init__() would register a new element and ID.
|
|
@@ -99,21 +99,41 @@ async def embed(
|
|
|
99
99
|
Embed a marimo app, with lean script-mode embeds.
|
|
100
100
|
|
|
101
101
|
In script mode, only the embedded notebook's ``result`` definition is
|
|
102
|
-
retained, so intermediate definitions
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
retained, so intermediate definitions can be released after the embed
|
|
103
|
+
completes. Pass additional definition names in ``keep`` to retain them as
|
|
104
|
+
well (in notebook mode all definitions are exposed and ``keep`` has no
|
|
105
|
+
effect). The embedded notebook's rendered cell outputs are stacked on the
|
|
106
|
+
returned object's ``output``, matching notebook-mode embeds.
|
|
107
|
+
|
|
108
|
+
When ``args`` is the only overridden definition, the embedded notebook's
|
|
109
|
+
interface also shows a CLI command that reproduces the embed's current
|
|
110
|
+
setup standalone.
|
|
106
111
|
|
|
107
112
|
This also works around marimo nested embed failures in script mode,
|
|
108
113
|
see https://github.com/marimo-team/marimo/issues/9572
|
|
109
114
|
"""
|
|
110
115
|
keep = _normalize_keep(keep)
|
|
116
|
+
_record_extra_overrides(defs)
|
|
111
117
|
if mo.running_in_notebook():
|
|
112
118
|
_raise_if_same_cell_app(app)
|
|
113
119
|
return await app.embed(defs=defs)
|
|
114
120
|
return await asyncio.to_thread(_embed_in_script, app, defs or {}, keep)
|
|
115
121
|
|
|
116
122
|
|
|
123
|
+
def _record_extra_overrides(defs: dict[str, typing.Any] | None) -> None:
|
|
124
|
+
"""Stamp on the injected args Group which other defs were overridden.
|
|
125
|
+
|
|
126
|
+
The embedded notebook's interface offers a CLI command reproducing the
|
|
127
|
+
current setup only when ``args`` is the only override: defs injected
|
|
128
|
+
directly (e.g. a dataframe) cannot be reproduced from the command line.
|
|
129
|
+
The interface cannot detect overrides itself — marimo merges them into
|
|
130
|
+
the run's globals without a trace — so embed() records them here.
|
|
131
|
+
"""
|
|
132
|
+
args = (defs or {}).get("args")
|
|
133
|
+
if args is not None and hasattr(args, "_embedded_extra_overrides"):
|
|
134
|
+
args._embedded_extra_overrides = frozenset(defs or ()) - {"args"}
|
|
135
|
+
|
|
136
|
+
|
|
117
137
|
def _normalize_keep(keep: typing.Sequence[str]) -> tuple[str, ...]:
|
|
118
138
|
if isinstance(keep, str):
|
|
119
139
|
raise TypeError("keep must be a sequence of definition names, not a string")
|
|
@@ -158,7 +178,7 @@ class Passthrough:
|
|
|
158
178
|
for name in ("result", *keep):
|
|
159
179
|
if name in source_defs:
|
|
160
180
|
self.defs[name] = source_defs[name]
|
|
161
|
-
self.output = None
|
|
181
|
+
self.output: mo.Html | None = None
|
|
162
182
|
|
|
163
183
|
def _forwarded(self) -> dict[str, typing.Any]:
|
|
164
184
|
return {k: v for k, v in self.defs.items() if k != "interface"}
|
|
@@ -207,10 +227,13 @@ class Passthrough:
|
|
|
207
227
|
|
|
208
228
|
|
|
209
229
|
def _embed_in_script(
|
|
210
|
-
app: _App,
|
|
230
|
+
app: _App,
|
|
231
|
+
defs: dict[str, typing.Any],
|
|
232
|
+
keep: tuple[str, ...] = (),
|
|
211
233
|
) -> typing.Any:
|
|
212
|
-
|
|
234
|
+
output, computed_defs = app.run(defs=defs)
|
|
213
235
|
result = Passthrough(dict(computed_defs), keep=keep)
|
|
214
236
|
if "interface" in computed_defs:
|
|
215
237
|
result.defs["interface"] = computed_defs["interface"]
|
|
238
|
+
result.output = mo.vstack([x for x in output if x is not None])
|
|
216
239
|
return result
|
|
@@ -6,10 +6,12 @@ import json
|
|
|
6
6
|
import typing
|
|
7
7
|
|
|
8
8
|
import marimo as mo
|
|
9
|
-
from hypothesis import strategies as st
|
|
10
9
|
|
|
11
10
|
from . import _options, _parse, _variant
|
|
12
11
|
|
|
12
|
+
if typing.TYPE_CHECKING:
|
|
13
|
+
from hypothesis import strategies as st
|
|
14
|
+
|
|
13
15
|
InputControl = _options.InputControl
|
|
14
16
|
ParseError = _options.ParseError
|
|
15
17
|
ParseResult = _options.ParseResult
|
|
@@ -17,30 +19,6 @@ ParseResult = _options.ParseResult
|
|
|
17
19
|
_UNSET: typing.Any = object()
|
|
18
20
|
|
|
19
21
|
|
|
20
|
-
class _ListUIOwner(typing.Protocol):
|
|
21
|
-
def current_items(self) -> list[typing.Any]: ...
|
|
22
|
-
|
|
23
|
-
def default_item(self) -> typing.Any: ...
|
|
24
|
-
|
|
25
|
-
def set_items(self, items: list[typing.Any]) -> None: ...
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@dataclasses.dataclass(frozen=True)
|
|
29
|
-
class _CallbackListUIOwner:
|
|
30
|
-
value_getter: typing.Callable[[], list[typing.Any]]
|
|
31
|
-
make_default: typing.Callable[[], typing.Any]
|
|
32
|
-
on_change: typing.Callable[[typing.Any], None]
|
|
33
|
-
|
|
34
|
-
def current_items(self) -> list[typing.Any]:
|
|
35
|
-
return self.value_getter()
|
|
36
|
-
|
|
37
|
-
def default_item(self) -> typing.Any:
|
|
38
|
-
return self.make_default()
|
|
39
|
-
|
|
40
|
-
def set_items(self, items: list[typing.Any]) -> None:
|
|
41
|
-
self.on_change(items)
|
|
42
|
-
|
|
43
|
-
|
|
44
22
|
class _ListUI:
|
|
45
23
|
"""Notebook UI wrapper for a list control.
|
|
46
24
|
|
|
@@ -77,7 +55,9 @@ class _ListUI:
|
|
|
77
55
|
def _build_list_ui(
|
|
78
56
|
elements: list[typing.Any],
|
|
79
57
|
*,
|
|
80
|
-
|
|
58
|
+
current_items: typing.Callable[[], list[typing.Any]],
|
|
59
|
+
default_item: typing.Callable[[], typing.Any],
|
|
60
|
+
set_items: typing.Callable[[list[typing.Any]], None],
|
|
81
61
|
) -> tuple[typing.Any, typing.Any]:
|
|
82
62
|
"""Build the notebook list layout with per-item controls.
|
|
83
63
|
|
|
@@ -93,21 +73,21 @@ def _build_list_ui(
|
|
|
93
73
|
"""
|
|
94
74
|
|
|
95
75
|
def insert_at(idx: int) -> None:
|
|
96
|
-
current =
|
|
97
|
-
|
|
76
|
+
current = current_items()
|
|
77
|
+
set_items([*current[:idx], default_item(), *current[idx:]])
|
|
98
78
|
|
|
99
79
|
def remove_at(idx: int) -> None:
|
|
100
|
-
current =
|
|
80
|
+
current = current_items()
|
|
101
81
|
if 0 <= idx < len(current):
|
|
102
|
-
|
|
82
|
+
set_items([*current[:idx], *current[idx + 1 :]])
|
|
103
83
|
|
|
104
84
|
def move(idx: int, delta: int) -> None:
|
|
105
|
-
current =
|
|
85
|
+
current = current_items()
|
|
106
86
|
target = idx + delta
|
|
107
87
|
if 0 <= idx < len(current) and 0 <= target < len(current):
|
|
108
88
|
reordered = list(current)
|
|
109
89
|
reordered[idx], reordered[target] = reordered[target], reordered[idx]
|
|
110
|
-
|
|
90
|
+
set_items(reordered)
|
|
111
91
|
|
|
112
92
|
count = len(elements)
|
|
113
93
|
rows: list[typing.Any] = []
|
|
@@ -143,7 +123,7 @@ def _build_list_ui(
|
|
|
143
123
|
|
|
144
124
|
add_btn = mo.ui.button(
|
|
145
125
|
label="+ Append",
|
|
146
|
-
on_click=lambda _: insert_at(len(
|
|
126
|
+
on_click=lambda _: insert_at(len(current_items())),
|
|
147
127
|
)
|
|
148
128
|
rows.append(add_btn)
|
|
149
129
|
return mo.vstack(rows), add_btn
|
|
@@ -504,21 +484,55 @@ class SubgroupListControl(InputControl):
|
|
|
504
484
|
return groups
|
|
505
485
|
|
|
506
486
|
def format_query_value(self, value: typing.Any) -> str | None:
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
487
|
+
# Serialize each item per leaf, keeping the dict structure but replacing
|
|
488
|
+
# each leaf value with its own control's query form (a dropdown's key,
|
|
489
|
+
# str(number), ...). This stays JSON-serializable even when a leaf value
|
|
490
|
+
# is not — e.g. a dropdown mapped to a class — where a raw json.dumps of
|
|
491
|
+
# the resolved values would raise TypeError and drop the whole param.
|
|
492
|
+
# Leaves whose query form is None (at their default) are omitted and
|
|
493
|
+
# refilled from the item template on parse.
|
|
494
|
+
items: list[dict[str, typing.Any]] = []
|
|
495
|
+
for item in value:
|
|
496
|
+
out: dict[str, typing.Any] = {}
|
|
497
|
+
for leaf in self.leaves:
|
|
498
|
+
raw = get_path(item, leaf.value_path, _UNSET)
|
|
499
|
+
if raw is _UNSET:
|
|
500
|
+
continue
|
|
501
|
+
formatted = leaf.bare_control().format_query_value(raw)
|
|
502
|
+
if formatted is not None:
|
|
503
|
+
_set_path(out, leaf.value_path, formatted)
|
|
504
|
+
items.append(out)
|
|
505
|
+
return json.dumps(items)
|
|
511
506
|
|
|
512
507
|
def parse_query_value(self, value: str) -> ParseResult | ParseError:
|
|
513
508
|
try:
|
|
514
|
-
|
|
509
|
+
raw: typing.Any = json.loads(value)
|
|
515
510
|
except json.JSONDecodeError:
|
|
516
511
|
return ParseError(f"Query parameter for {self.option} must be a JSON list")
|
|
517
|
-
if not isinstance(
|
|
512
|
+
if not isinstance(raw, list):
|
|
518
513
|
return ParseError(f"Query parameter for {self.option} must be a JSON list")
|
|
519
|
-
|
|
514
|
+
result: list[dict[str, typing.Any]] = []
|
|
515
|
+
for raw_item in typing.cast(list[typing.Any], raw):
|
|
516
|
+
item = copy.deepcopy(self.item_template_default)
|
|
517
|
+
if isinstance(raw_item, dict):
|
|
518
|
+
for leaf in self.leaves:
|
|
519
|
+
stored = get_path(raw_item, leaf.value_path, _UNSET)
|
|
520
|
+
if stored is _UNSET:
|
|
521
|
+
continue
|
|
522
|
+
if not isinstance(stored, str):
|
|
523
|
+
# Legacy form stored the resolved value directly.
|
|
524
|
+
_set_path(item, leaf.value_path, stored)
|
|
525
|
+
continue
|
|
526
|
+
parsed = leaf.bare_control().parse_query_value(stored)
|
|
527
|
+
if isinstance(parsed, ParseError):
|
|
528
|
+
return parsed
|
|
529
|
+
_set_path(item, leaf.value_path, parsed.value)
|
|
530
|
+
result.append(item)
|
|
531
|
+
return ParseResult(result)
|
|
520
532
|
|
|
521
533
|
def strategy(self) -> st.SearchStrategy:
|
|
534
|
+
from hypothesis import strategies as st
|
|
535
|
+
|
|
522
536
|
leaf_strategies = {
|
|
523
537
|
leaf.value_path: leaf.bare_control().strategy() for leaf in self.leaves
|
|
524
538
|
}
|
|
@@ -545,18 +559,17 @@ class SubgroupListControl(InputControl):
|
|
|
545
559
|
items = [copy.deepcopy(item) for item in value]
|
|
546
560
|
elements = [self.item_builder(i, item) for i, item in enumerate(items)]
|
|
547
561
|
if on_change is not None and mo.running_in_notebook():
|
|
548
|
-
self._attach_item_change_handlers(elements, on_change)
|
|
549
562
|
|
|
550
563
|
def value_getter() -> list[typing.Any]:
|
|
551
564
|
return [element.value for element in elements]
|
|
552
565
|
|
|
566
|
+
self._attach_item_change_handlers(elements, on_change, value_getter)
|
|
567
|
+
|
|
553
568
|
display, add_btn = _build_list_ui(
|
|
554
569
|
elements,
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
on_change=on_change,
|
|
559
|
-
),
|
|
570
|
+
current_items=value_getter,
|
|
571
|
+
default_item=lambda: copy.deepcopy(self.item_template_default),
|
|
572
|
+
set_items=on_change,
|
|
560
573
|
)
|
|
561
574
|
return _ListUI(
|
|
562
575
|
_ElementList(elements),
|
|
@@ -570,10 +583,8 @@ class SubgroupListControl(InputControl):
|
|
|
570
583
|
self,
|
|
571
584
|
item_elements: list[typing.Any],
|
|
572
585
|
on_change: typing.Callable[[typing.Any], None],
|
|
586
|
+
value_getter: typing.Callable[[], list[typing.Any]],
|
|
573
587
|
) -> None:
|
|
574
|
-
def value_getter() -> list[typing.Any]:
|
|
575
|
-
return [element.value for element in item_elements]
|
|
576
|
-
|
|
577
588
|
for idx, item_element in enumerate(item_elements):
|
|
578
589
|
for leaf in self.leaves:
|
|
579
590
|
element = _element_at_path(item_element, leaf.value_path)
|
|
@@ -773,8 +784,6 @@ class ListControl(InputControl):
|
|
|
773
784
|
|
|
774
785
|
def format_query_value(self, value: typing.Any) -> str | None:
|
|
775
786
|
items = list(value)
|
|
776
|
-
if not items and not self.default:
|
|
777
|
-
return None
|
|
778
787
|
return json.dumps([self.item_control.format_query_value(v) for v in items])
|
|
779
788
|
|
|
780
789
|
def parse_query_value(self, value: str) -> ParseResult | ParseError:
|
|
@@ -802,6 +811,8 @@ class ListControl(InputControl):
|
|
|
802
811
|
return [_options.option_value_token(self.item_control.option, query_value)]
|
|
803
812
|
|
|
804
813
|
def strategy(self) -> st.SearchStrategy:
|
|
814
|
+
from hypothesis import strategies as st
|
|
815
|
+
|
|
805
816
|
return st.lists(self.item_control.strategy())
|
|
806
817
|
|
|
807
818
|
def create_marimo_element(
|
|
@@ -826,7 +837,7 @@ class ListControl(InputControl):
|
|
|
826
837
|
return handler
|
|
827
838
|
|
|
828
839
|
elements = [
|
|
829
|
-
self.item_control.
|
|
840
|
+
self.item_control.make_element(
|
|
830
841
|
v,
|
|
831
842
|
label=f"{label} [{i + 1}]",
|
|
832
843
|
disabled=disabled,
|
|
@@ -842,11 +853,9 @@ class ListControl(InputControl):
|
|
|
842
853
|
|
|
843
854
|
display, add_btn = _build_list_ui(
|
|
844
855
|
elements,
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
on_change=on_change,
|
|
849
|
-
),
|
|
856
|
+
current_items=value_getter,
|
|
857
|
+
default_item=lambda: copy.deepcopy(self.item_control.default),
|
|
858
|
+
set_items=on_change,
|
|
850
859
|
)
|
|
851
860
|
return _ListUI(
|
|
852
861
|
array,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import abc
|
|
2
4
|
import contextlib
|
|
3
5
|
import dataclasses
|
|
@@ -9,19 +11,17 @@ import sys
|
|
|
9
11
|
import typing
|
|
10
12
|
|
|
11
13
|
import marimo as mo
|
|
12
|
-
from hypothesis import strategies as st
|
|
13
14
|
|
|
14
15
|
from . import _choice_options, _custom_element, _parse, _ui_workarounds
|
|
15
16
|
|
|
17
|
+
if typing.TYPE_CHECKING:
|
|
18
|
+
from hypothesis import strategies as st
|
|
19
|
+
|
|
16
20
|
Numeric = int | float
|
|
17
21
|
|
|
18
22
|
_UNSET: typing.Any = object()
|
|
19
23
|
|
|
20
24
|
|
|
21
|
-
def _empty_cli_opts() -> dict[str, str]:
|
|
22
|
-
return {}
|
|
23
|
-
|
|
24
|
-
|
|
25
25
|
def option_value_token(option: str, value: str) -> str:
|
|
26
26
|
"""Serialize an ``option value`` pair as one CLI token string.
|
|
27
27
|
|
|
@@ -52,8 +52,19 @@ class InputControl(abc.ABC):
|
|
|
52
52
|
option: str
|
|
53
53
|
help_text: str
|
|
54
54
|
default: typing.Any
|
|
55
|
-
extra_kwargs: dict[str, typing.Any] = dataclasses.field(
|
|
56
|
-
default_factory=dict, kw_only=True
|
|
55
|
+
extra_kwargs: dict[str, typing.Any] = dataclasses.field(
|
|
56
|
+
default_factory=dict[str, typing.Any], kw_only=True
|
|
57
|
+
)
|
|
58
|
+
# Set by Group.custom(): a factory that builds a notebook-only component
|
|
59
|
+
# from this control's resolved value, and a value_fn mapping
|
|
60
|
+
# (component, fallback) to the control's value shape. When set, make_element
|
|
61
|
+
# wraps the control's own element in a CustomElement in notebooks; the
|
|
62
|
+
# control keeps all its CLI/parse/help behavior as the fallback.
|
|
63
|
+
custom_build: typing.Callable[[typing.Any], typing.Any] | None = dataclasses.field(
|
|
64
|
+
default=None, kw_only=True
|
|
65
|
+
)
|
|
66
|
+
custom_value_fn: _custom_element.CustomValueFn | None = dataclasses.field(
|
|
67
|
+
default=None, kw_only=True
|
|
57
68
|
)
|
|
58
69
|
|
|
59
70
|
def options(self) -> set[str]:
|
|
@@ -68,7 +79,7 @@ class InputControl(abc.ABC):
|
|
|
68
79
|
"""Whether this control accepts repeated CLI values for the same option."""
|
|
69
80
|
return False
|
|
70
81
|
|
|
71
|
-
def with_option(self, option: str) ->
|
|
82
|
+
def with_option(self, option: str) -> InputControl:
|
|
72
83
|
"""Return a copy of this control with a different option name.
|
|
73
84
|
|
|
74
85
|
Used by ``controls_from`` to re-prefix a mirrored control. Subclasses
|
|
@@ -135,6 +146,31 @@ class InputControl(abc.ABC):
|
|
|
135
146
|
) -> typing.Any:
|
|
136
147
|
"""Create the marimo UI element for this control."""
|
|
137
148
|
|
|
149
|
+
def make_element(
|
|
150
|
+
self,
|
|
151
|
+
value: typing.Any,
|
|
152
|
+
label: str,
|
|
153
|
+
*,
|
|
154
|
+
on_change: typing.Callable[[typing.Any], None] | None = None,
|
|
155
|
+
disabled: bool = False,
|
|
156
|
+
) -> typing.Any:
|
|
157
|
+
"""Create this control's element, applying any Group.custom() wrapper.
|
|
158
|
+
|
|
159
|
+
Ordinary controls just return ``create_marimo_element``; a control set up
|
|
160
|
+
by ``Group.custom()`` wraps that element in a ``CustomElement`` (notebook
|
|
161
|
+
only) so it renders a bespoke component while keeping this control's CLI
|
|
162
|
+
fallback. Use this instead of ``create_marimo_element`` wherever an
|
|
163
|
+
element is built from a control that may carry a custom wrapper.
|
|
164
|
+
"""
|
|
165
|
+
element = self.create_marimo_element(
|
|
166
|
+
value, label, on_change=on_change, disabled=disabled
|
|
167
|
+
)
|
|
168
|
+
if self.custom_build is None or not mo.running_in_notebook():
|
|
169
|
+
return element
|
|
170
|
+
return _custom_element.CustomElement(
|
|
171
|
+
self.custom_build(value), element, self.custom_value_fn
|
|
172
|
+
)
|
|
173
|
+
|
|
138
174
|
@abc.abstractmethod
|
|
139
175
|
def prompt_interactive(self, effective_default: typing.Any = _UNSET) -> list[str]:
|
|
140
176
|
"""Prompt the user for a value. Returns raw CLI tokens to append to args.
|
|
@@ -223,6 +259,8 @@ class FlagControl(InputControl):
|
|
|
223
259
|
)
|
|
224
260
|
|
|
225
261
|
def strategy(self) -> st.SearchStrategy:
|
|
262
|
+
from hypothesis import strategies as st
|
|
263
|
+
|
|
226
264
|
return st.booleans()
|
|
227
265
|
|
|
228
266
|
def format_usage_parts(self) -> list[str]:
|
|
@@ -320,6 +358,8 @@ class TextControl(ValueControl):
|
|
|
320
358
|
return None if res is None else ParseResult(res)
|
|
321
359
|
|
|
322
360
|
def strategy(self) -> st.SearchStrategy:
|
|
361
|
+
from hypothesis import strategies as st
|
|
362
|
+
|
|
323
363
|
return st.text()
|
|
324
364
|
|
|
325
365
|
def format_help_lines(self) -> list[str]:
|
|
@@ -375,6 +415,12 @@ def _decode_json_list(value: str) -> list[typing.Any] | None:
|
|
|
375
415
|
return typing.cast(list[typing.Any], raw)
|
|
376
416
|
|
|
377
417
|
|
|
418
|
+
def _missing_path_error(value: str) -> ParseError | None:
|
|
419
|
+
if value and not pathlib.Path(value).exists():
|
|
420
|
+
return ParseError(f"File not found: {value!r}")
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
|
|
378
424
|
@dataclasses.dataclass
|
|
379
425
|
class FileControl(TextControl):
|
|
380
426
|
def create_marimo_element(
|
|
@@ -407,9 +453,8 @@ class FileControl(TextControl):
|
|
|
407
453
|
def parse(self, args: _parse.ParsedArgs) -> ParseResult | ParseError | None:
|
|
408
454
|
result = super().parse(args)
|
|
409
455
|
match result:
|
|
410
|
-
case ParseResult(value=v)
|
|
411
|
-
|
|
412
|
-
return ParseError(f"File not found: {v!r}")
|
|
456
|
+
case ParseResult(value=v):
|
|
457
|
+
return _missing_path_error(v) or result
|
|
413
458
|
case _:
|
|
414
459
|
pass
|
|
415
460
|
return result
|
|
@@ -420,8 +465,8 @@ class FileControl(TextControl):
|
|
|
420
465
|
if not result:
|
|
421
466
|
return result
|
|
422
467
|
v = result[1]
|
|
423
|
-
if
|
|
424
|
-
print(
|
|
468
|
+
if err := _missing_path_error(v):
|
|
469
|
+
print(err.message)
|
|
425
470
|
continue
|
|
426
471
|
return result
|
|
427
472
|
|
|
@@ -467,8 +512,8 @@ class MultiFileControl(ValueControl):
|
|
|
467
512
|
for value in values:
|
|
468
513
|
if value is None:
|
|
469
514
|
return ParseError(f"Option {self.option} requires a value")
|
|
470
|
-
if
|
|
471
|
-
return
|
|
515
|
+
if err := _missing_path_error(value):
|
|
516
|
+
return err
|
|
472
517
|
paths.append(value)
|
|
473
518
|
return ParseResult(paths)
|
|
474
519
|
|
|
@@ -484,12 +529,14 @@ class MultiFileControl(ValueControl):
|
|
|
484
529
|
return ParseError(
|
|
485
530
|
f"Query parameter for {self.option} must be a JSON list of paths"
|
|
486
531
|
)
|
|
487
|
-
if
|
|
488
|
-
return
|
|
532
|
+
if err := _missing_path_error(item):
|
|
533
|
+
return err
|
|
489
534
|
paths.append(item)
|
|
490
535
|
return ParseResult(paths)
|
|
491
536
|
|
|
492
537
|
def strategy(self) -> st.SearchStrategy:
|
|
538
|
+
from hypothesis import strategies as st
|
|
539
|
+
|
|
493
540
|
return st.lists(st.text())
|
|
494
541
|
|
|
495
542
|
def format_usage_parts(self) -> list[str]:
|
|
@@ -522,9 +569,9 @@ class MultiFileControl(ValueControl):
|
|
|
522
569
|
if not response:
|
|
523
570
|
return []
|
|
524
571
|
paths = [part.strip() for part in response.split(",") if part.strip()]
|
|
525
|
-
|
|
526
|
-
if
|
|
527
|
-
print(
|
|
572
|
+
errors = [err for path in paths if (err := _missing_path_error(path))]
|
|
573
|
+
if errors:
|
|
574
|
+
print(errors[0].message)
|
|
528
575
|
continue
|
|
529
576
|
return [tok for path in paths for tok in (self.option, path)]
|
|
530
577
|
|
|
@@ -569,6 +616,8 @@ class TextAreaControl(ValueControl):
|
|
|
569
616
|
return None if res is None else ParseResult(res)
|
|
570
617
|
|
|
571
618
|
def strategy(self) -> st.SearchStrategy:
|
|
619
|
+
from hypothesis import strategies as st
|
|
620
|
+
|
|
572
621
|
return st.text()
|
|
573
622
|
|
|
574
623
|
def format_usage_parts(self) -> list[str]:
|
|
@@ -655,6 +704,8 @@ class NumberControl(_NoneFlag, ValueControl):
|
|
|
655
704
|
return super().parse_query_value(value)
|
|
656
705
|
|
|
657
706
|
def strategy(self) -> st.SearchStrategy:
|
|
707
|
+
from hypothesis import strategies as st
|
|
708
|
+
|
|
658
709
|
numbers = st.integers() | st.floats(allow_nan=False, allow_infinity=False)
|
|
659
710
|
return st.one_of(st.none(), numbers) if self._is_none_capable else numbers
|
|
660
711
|
|
|
@@ -716,7 +767,7 @@ class RangeControl(ValueControl):
|
|
|
716
767
|
steps: typing.Sequence[Numeric] | None,
|
|
717
768
|
step: Numeric | None = None,
|
|
718
769
|
extra_kwargs: dict[str, typing.Any] | None = None,
|
|
719
|
-
) ->
|
|
770
|
+
) -> RangeControl:
|
|
720
771
|
return cls(
|
|
721
772
|
option=option,
|
|
722
773
|
metavar=metavar,
|
|
@@ -790,11 +841,15 @@ class RangeControl(ValueControl):
|
|
|
790
841
|
return ParseResult(parsed)
|
|
791
842
|
|
|
792
843
|
def strategy(self) -> st.SearchStrategy:
|
|
844
|
+
from hypothesis import strategies as st
|
|
845
|
+
|
|
793
846
|
return st.tuples(self._number_strategy(), self._number_strategy()).map(
|
|
794
847
|
lambda pair: sorted(pair)
|
|
795
848
|
)
|
|
796
849
|
|
|
797
850
|
def _number_strategy(self) -> st.SearchStrategy[Numeric]:
|
|
851
|
+
from hypothesis import strategies as st
|
|
852
|
+
|
|
798
853
|
if self.allowed_values is not None:
|
|
799
854
|
return st.sampled_from(self.allowed_values)
|
|
800
855
|
if self.start is not None and self.stop is not None:
|
|
@@ -913,6 +968,8 @@ class MultiSelectControl(_NoneFlag, ValueControl):
|
|
|
913
968
|
return ParseResult([self.select_opts[item] for item in keys])
|
|
914
969
|
|
|
915
970
|
def strategy(self) -> st.SearchStrategy:
|
|
971
|
+
from hypothesis import strategies as st
|
|
972
|
+
|
|
916
973
|
if not self.select_opts:
|
|
917
974
|
return st.just([])
|
|
918
975
|
return st.lists(st.sampled_from(list(self.select_opts.values())), unique=True)
|
|
@@ -990,7 +1047,7 @@ class DropdownControl(_NoneFlag, InputControl):
|
|
|
990
1047
|
dropdown_opts: dict[str, typing.Any]
|
|
991
1048
|
supports_none: bool
|
|
992
1049
|
default: str | None
|
|
993
|
-
cli_opts: dict[str, str] = dataclasses.field(default_factory=
|
|
1050
|
+
cli_opts: dict[str, str] = dataclasses.field(default_factory=dict[str, str])
|
|
994
1051
|
|
|
995
1052
|
def __post_init__(self) -> None:
|
|
996
1053
|
if not self.cli_opts:
|
|
@@ -1052,6 +1109,8 @@ class DropdownControl(_NoneFlag, InputControl):
|
|
|
1052
1109
|
return ParseResult(key)
|
|
1053
1110
|
|
|
1054
1111
|
def strategy(self) -> st.SearchStrategy:
|
|
1112
|
+
from hypothesis import strategies as st
|
|
1113
|
+
|
|
1055
1114
|
return st.sampled_from(
|
|
1056
1115
|
[None, *self.dropdown_opts.keys()]
|
|
1057
1116
|
if self.supports_none
|
|
@@ -1138,102 +1197,6 @@ class DropdownControl(_NoneFlag, InputControl):
|
|
|
1138
1197
|
)
|
|
1139
1198
|
|
|
1140
1199
|
|
|
1141
|
-
@dataclasses.dataclass
|
|
1142
|
-
class CustomControl(InputControl):
|
|
1143
|
-
"""A notebook-only component paired with a CLI-compatible fallback control.
|
|
1144
|
-
|
|
1145
|
-
``inner`` is the fallback control that supplies all CLI behavior (parsing,
|
|
1146
|
-
help, defaults, query format). ``build`` constructs the notebook component
|
|
1147
|
-
from the fallback's live marimo element; it is only called in notebooks.
|
|
1148
|
-
``value_fn`` maps ``(component, fallback)`` to the fallback-shaped value.
|
|
1149
|
-
|
|
1150
|
-
Because all CLI behavior lives in ``inner`` and the component is rebuilt by
|
|
1151
|
-
``create_marimo_element``, ``controls_from`` recreates the component when it
|
|
1152
|
-
mirrors this control into a parent notebook.
|
|
1153
|
-
"""
|
|
1154
|
-
|
|
1155
|
-
inner: InputControl
|
|
1156
|
-
build: typing.Callable[[typing.Any], typing.Any]
|
|
1157
|
-
value_fn: _custom_element.CustomValueFn | None = None
|
|
1158
|
-
|
|
1159
|
-
# ``build`` receives the fallback's resolved *value* (a snapshot), not the
|
|
1160
|
-
# live element. controls_from creates the fallback and calls build in one
|
|
1161
|
-
# cell, and marimo forbids reading a UIElement's ``.value`` in the cell that
|
|
1162
|
-
# created it -- so build must not touch the element. ``value_fn`` does
|
|
1163
|
-
# receive the element, but it runs later (when the parent reads the mirror
|
|
1164
|
-
# dictionary's value), in a different cell, where ``.value`` is allowed.
|
|
1165
|
-
|
|
1166
|
-
@classmethod
|
|
1167
|
-
def wrap(
|
|
1168
|
-
cls,
|
|
1169
|
-
inner: InputControl,
|
|
1170
|
-
build: typing.Callable[[typing.Any], typing.Any],
|
|
1171
|
-
value_fn: _custom_element.CustomValueFn | None,
|
|
1172
|
-
) -> "CustomControl":
|
|
1173
|
-
return cls(
|
|
1174
|
-
option=inner.option,
|
|
1175
|
-
help_text=inner.help_text,
|
|
1176
|
-
default=inner.default,
|
|
1177
|
-
inner=inner,
|
|
1178
|
-
build=build,
|
|
1179
|
-
value_fn=value_fn,
|
|
1180
|
-
)
|
|
1181
|
-
|
|
1182
|
-
def with_option(self, option: str) -> "CustomControl":
|
|
1183
|
-
inner = self.inner.with_option(option)
|
|
1184
|
-
return dataclasses.replace(
|
|
1185
|
-
self, option=option, inner=inner, default=inner.default
|
|
1186
|
-
)
|
|
1187
|
-
|
|
1188
|
-
def options(self) -> set[str]:
|
|
1189
|
-
return self.inner.options()
|
|
1190
|
-
|
|
1191
|
-
def flags(self) -> set[str]:
|
|
1192
|
-
return self.inner.flags()
|
|
1193
|
-
|
|
1194
|
-
def allows_repeated_values(self) -> bool:
|
|
1195
|
-
return self.inner.allows_repeated_values()
|
|
1196
|
-
|
|
1197
|
-
def parse(self, args: _parse.ParsedArgs) -> ParseResult | ParseError | None:
|
|
1198
|
-
return self.inner.parse(args)
|
|
1199
|
-
|
|
1200
|
-
def parse_query_value(self, value: str) -> ParseResult | ParseError:
|
|
1201
|
-
return self.inner.parse_query_value(value)
|
|
1202
|
-
|
|
1203
|
-
def format_query_value(self, value: typing.Any) -> str | None:
|
|
1204
|
-
return self.inner.format_query_value(value)
|
|
1205
|
-
|
|
1206
|
-
def strategy(self) -> st.SearchStrategy:
|
|
1207
|
-
return self.inner.strategy()
|
|
1208
|
-
|
|
1209
|
-
def format_usage_parts(self) -> list[str]:
|
|
1210
|
-
return self.inner.format_usage_parts()
|
|
1211
|
-
|
|
1212
|
-
def format_help_lines(self) -> list[str]:
|
|
1213
|
-
return self.inner.format_help_lines()
|
|
1214
|
-
|
|
1215
|
-
def format_value(self, value: typing.Any) -> list[str]:
|
|
1216
|
-
return self.inner.format_value(value)
|
|
1217
|
-
|
|
1218
|
-
def prompt_interactive(self, effective_default: typing.Any = _UNSET) -> list[str]:
|
|
1219
|
-
return self.inner.prompt_interactive(effective_default)
|
|
1220
|
-
|
|
1221
|
-
def create_marimo_element(
|
|
1222
|
-
self,
|
|
1223
|
-
value: typing.Any,
|
|
1224
|
-
label: str,
|
|
1225
|
-
*,
|
|
1226
|
-
on_change: typing.Callable[[typing.Any], None] | None = None,
|
|
1227
|
-
disabled: bool = False,
|
|
1228
|
-
) -> typing.Any:
|
|
1229
|
-
fallback = self.inner.create_marimo_element(
|
|
1230
|
-
value, label, on_change=on_change, disabled=disabled
|
|
1231
|
-
)
|
|
1232
|
-
if not mo.running_in_notebook():
|
|
1233
|
-
return fallback
|
|
1234
|
-
return _custom_element.CustomElement(self.build(value), fallback, self.value_fn)
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
1200
|
def _parse_number(option: str, value: str) -> ParseResult | ParseError:
|
|
1238
1201
|
# Parse integer-looking strings as int first: routing them through float()
|
|
1239
1202
|
# would silently lose precision past 2**53 (e.g. "9007199254740993").
|
|
@@ -8,21 +8,16 @@ from . import _options
|
|
|
8
8
|
_MARIMO_RESERVED_PARAMS = frozenset({"file"})
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
class QueryParamStore(typing.Protocol):
|
|
12
|
-
def __iter__(self) -> typing.Iterator[typing.Any]: ...
|
|
13
|
-
|
|
14
|
-
def __setitem__(self, key: str, value: str) -> None: ...
|
|
15
|
-
|
|
16
|
-
def get(self, key: str) -> typing.Any: ...
|
|
17
|
-
|
|
18
|
-
|
|
19
11
|
def escape_url_key(key: str) -> str:
|
|
20
12
|
return f"{key}_" if key in _MARIMO_RESERVED_PARAMS else key
|
|
21
13
|
|
|
22
14
|
|
|
23
15
|
@dataclasses.dataclass
|
|
24
16
|
class QueryParams:
|
|
25
|
-
params
|
|
17
|
+
# marimo's query-params object (mo.query_params()), or None outside a
|
|
18
|
+
# notebook. Duck-typed: get / __setitem__ / __iter__, plus an optional
|
|
19
|
+
# remove() used when clearing a key.
|
|
20
|
+
params: typing.Any
|
|
26
21
|
prefix: str = ""
|
|
27
22
|
|
|
28
23
|
@classmethod
|
|
@@ -100,8 +95,6 @@ class QueryParams:
|
|
|
100
95
|
if callable(remove):
|
|
101
96
|
remove(key)
|
|
102
97
|
else:
|
|
103
|
-
|
|
104
|
-
key, None
|
|
105
|
-
)
|
|
98
|
+
params.pop(key, None)
|
|
106
99
|
else:
|
|
107
100
|
params[key] = value
|
|
@@ -9,46 +9,36 @@ dependency on a plotting library.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import base64
|
|
12
|
-
import enum
|
|
13
12
|
import io
|
|
14
13
|
import os
|
|
15
14
|
import sys
|
|
16
15
|
import typing
|
|
17
16
|
|
|
18
17
|
|
|
19
|
-
class Protocol(enum.Enum):
|
|
20
|
-
"""Terminal image protocol detected for the current stdout."""
|
|
21
|
-
|
|
22
|
-
KITTY = "kitty"
|
|
23
|
-
NONE = "none" # room for ITERM / SIXEL backends later
|
|
24
|
-
|
|
25
|
-
|
|
26
18
|
def detect(
|
|
27
19
|
env: typing.Mapping[str, str] | None = None,
|
|
28
20
|
stream: typing.IO[str] | None = None,
|
|
29
|
-
) ->
|
|
21
|
+
) -> bool:
|
|
30
22
|
"""Best-effort detection of inline-image support for ``stream``.
|
|
31
23
|
|
|
32
|
-
Returns ``
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
24
|
+
Returns ``False`` whenever stdout is not a terminal (piped or redirected),
|
|
25
|
+
so escape sequences never leak into captured output. Terminal identification
|
|
26
|
+
is by environment variable; this covers kitty, Ghostty, WezTerm, and
|
|
27
|
+
Konsole, all of which speak the Kitty graphics protocol.
|
|
36
28
|
"""
|
|
37
29
|
env = os.environ if env is None else env
|
|
38
30
|
out = sys.stdout if stream is None else stream
|
|
39
31
|
if out is None or not (hasattr(out, "isatty") and out.isatty()):
|
|
40
|
-
return
|
|
32
|
+
return False
|
|
41
33
|
term = env.get("TERM", "")
|
|
42
|
-
|
|
34
|
+
return (
|
|
43
35
|
"KITTY_WINDOW_ID" in env
|
|
44
36
|
or "kitty" in term
|
|
45
37
|
or term == "xterm-ghostty"
|
|
46
38
|
or "GHOSTTY_BIN_DIR" in env
|
|
47
39
|
or env.get("TERM_PROGRAM") == "WezTerm"
|
|
48
40
|
or "KONSOLE_VERSION" in env
|
|
49
|
-
)
|
|
50
|
-
return Protocol.KITTY
|
|
51
|
-
return Protocol.NONE
|
|
41
|
+
)
|
|
52
42
|
|
|
53
43
|
|
|
54
44
|
def to_png(fig: typing.Any, *, dpi: int | None = None) -> bytes:
|
|
@@ -102,7 +92,7 @@ def emit(png: bytes, *, stream: typing.IO[str] | None = None) -> None:
|
|
|
102
92
|
fallback (e.g. ASCII art) when they want one.
|
|
103
93
|
"""
|
|
104
94
|
out = sys.stdout if stream is None else stream
|
|
105
|
-
if out is not None and detect(stream=out)
|
|
95
|
+
if out is not None and detect(stream=out):
|
|
106
96
|
out.write(kitty_sequence(png) + "\n")
|
|
107
97
|
out.flush()
|
|
108
98
|
else:
|
|
@@ -52,7 +52,6 @@ class LockedMultiselect(UIElement[str, list[str]]):
|
|
|
52
52
|
|
|
53
53
|
def __init__(self, value: list[str], label_html: str) -> None:
|
|
54
54
|
self._locked_value = list(value)
|
|
55
|
-
self._label_html = label_html
|
|
56
55
|
_chips = "".join(
|
|
57
56
|
f'<span style="background:var(--sky-2,#dbeafe);border-radius:4px;'
|
|
58
57
|
f'padding:2px 8px;margin:2px;display:inline-block">{v}</span>'
|
|
@@ -31,12 +31,19 @@ class ValueResolver:
|
|
|
31
31
|
return self.overrides[key]
|
|
32
32
|
query_value = self._query_value(control, key)
|
|
33
33
|
if self.preset_state is not None:
|
|
34
|
+
# Honor live notebook list state over the preset: if the persisted
|
|
35
|
+
# query matches the value the caller passed (so it reflects the
|
|
36
|
+
# current edited state rather than a stale param), keep it. Compare
|
|
37
|
+
# in parsed/canonical form by round-tripping `default` through the
|
|
38
|
+
# same query (de)serialization — a list's live value holds mapped
|
|
39
|
+
# objects (e.g. a dropdown's resolved value) that don't compare
|
|
40
|
+
# equal to the parsed query's key form directly.
|
|
34
41
|
if (
|
|
35
42
|
_is_list_control(control)
|
|
36
43
|
and query_value is not _UNSET
|
|
37
|
-
and query_value == default
|
|
44
|
+
and query_value == self._normalized_default(control, default)
|
|
38
45
|
):
|
|
39
|
-
return
|
|
46
|
+
return default
|
|
40
47
|
match control.parse(self.preset_state.args):
|
|
41
48
|
case _options.ParseResult(value=v):
|
|
42
49
|
self.query_params.sync(control, key, v)
|
|
@@ -67,6 +74,26 @@ class ValueResolver:
|
|
|
67
74
|
pass
|
|
68
75
|
return default
|
|
69
76
|
|
|
77
|
+
def _normalized_default(
|
|
78
|
+
self,
|
|
79
|
+
control: _options.InputControl,
|
|
80
|
+
default: typing.Any,
|
|
81
|
+
) -> typing.Any:
|
|
82
|
+
"""``default`` round-tripped through the control's query (de)serialization.
|
|
83
|
+
|
|
84
|
+
Returns it in the same parsed form a query value takes, so the two can
|
|
85
|
+
be compared directly. Returns ``_UNSET`` (which never equals a real
|
|
86
|
+
query value) when ``default`` cannot be serialized or parsed back.
|
|
87
|
+
"""
|
|
88
|
+
formatted = control.format_query_value(default)
|
|
89
|
+
if formatted is None:
|
|
90
|
+
return _UNSET
|
|
91
|
+
match control.parse_query_value(formatted):
|
|
92
|
+
case _options.ParseResult(value=v):
|
|
93
|
+
return v
|
|
94
|
+
case _:
|
|
95
|
+
return _UNSET
|
|
96
|
+
|
|
70
97
|
def _query_value(
|
|
71
98
|
self,
|
|
72
99
|
control: _options.InputControl,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import dataclasses
|
|
3
4
|
import enum
|
|
4
5
|
import inspect
|
|
5
6
|
import pathlib
|
|
@@ -46,6 +47,27 @@ class OutputMode(enum.Enum):
|
|
|
46
47
|
STDOUT = "stdout" # print text to the terminal
|
|
47
48
|
|
|
48
49
|
|
|
50
|
+
def _ensure_nongui_matplotlib() -> None:
|
|
51
|
+
"""Switch matplotlib off GUI backends for CLI figure rendering.
|
|
52
|
+
|
|
53
|
+
Imports matplotlib if needed: when an app offloaded to a worker thread is
|
|
54
|
+
the first to import it, the backend would be resolved in that thread,
|
|
55
|
+
picking the GUI backend on macOS. A no-op when matplotlib is not
|
|
56
|
+
installed. Notebook backends are not registered as interactive and are
|
|
57
|
+
left untouched.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
import matplotlib
|
|
61
|
+
from matplotlib.backends.registry import BackendFilter, backend_registry
|
|
62
|
+
except ImportError:
|
|
63
|
+
return
|
|
64
|
+
interactive = backend_registry.list_builtin( # type: ignore[reportUnknownMemberType]
|
|
65
|
+
BackendFilter.INTERACTIVE
|
|
66
|
+
)
|
|
67
|
+
if matplotlib.get_backend().lower() in {b.lower() for b in interactive}:
|
|
68
|
+
matplotlib.use("agg")
|
|
69
|
+
|
|
70
|
+
|
|
49
71
|
class Group:
|
|
50
72
|
"""Unified CLI argument parser and marimo UI element generator."""
|
|
51
73
|
|
|
@@ -74,6 +96,10 @@ class Group:
|
|
|
74
96
|
self._query_params = _query_params.QueryParams.from_notebook()
|
|
75
97
|
self._preset_state = self._resolve_preset_state()
|
|
76
98
|
self._parent_group: Group | None = None
|
|
99
|
+
# Set by moops.embed() on the injected args: names of defs other than
|
|
100
|
+
# "args" overridden for the embed. None means not embedded via
|
|
101
|
+
# moops.embed (or overrides unknown).
|
|
102
|
+
self._embedded_extra_overrides: frozenset[str] | None = None
|
|
77
103
|
self._markdown_heading_offset = 0
|
|
78
104
|
self._disabled = False
|
|
79
105
|
self._variant_ctx = _variant.VariantContext()
|
|
@@ -258,6 +284,7 @@ class Group:
|
|
|
258
284
|
extra_missing_options=extra_missing_options,
|
|
259
285
|
disabled=self._disabled,
|
|
260
286
|
variant_ctx=self._variant_ctx,
|
|
287
|
+
embedded_extra_overrides=self._embedded_extra_overrides,
|
|
261
288
|
)
|
|
262
289
|
if self._parent_group is not None:
|
|
263
290
|
self._parent_group._subgroup_registry.register(iface)
|
|
@@ -302,12 +329,21 @@ class Group:
|
|
|
302
329
|
to a text/ASCII fallback when it is ``False``::
|
|
303
330
|
|
|
304
331
|
mo.stop(not args.graphics_supported)
|
|
332
|
+
|
|
333
|
+
A ``True`` on the CLI also switches matplotlib off GUI backends, so
|
|
334
|
+
that the plotting it gates works when the app runs in a worker thread
|
|
335
|
+
(e.g. embedded by another notebook) — GUI backends can only create
|
|
336
|
+
figures on the main thread, and ``figure`` only rasterizes, never
|
|
337
|
+
showing GUI windows.
|
|
305
338
|
"""
|
|
306
339
|
if self.output_mode is OutputMode.NOTEBOOK:
|
|
307
340
|
return True
|
|
308
341
|
if self.output_mode is None:
|
|
309
342
|
return False
|
|
310
|
-
|
|
343
|
+
if not _terminal_graphics.detect():
|
|
344
|
+
return False
|
|
345
|
+
_ensure_nongui_matplotlib()
|
|
346
|
+
return True
|
|
311
347
|
|
|
312
348
|
def figure(
|
|
313
349
|
self,
|
|
@@ -616,7 +652,9 @@ class Group:
|
|
|
616
652
|
inner = self._input_map.get(fallback)
|
|
617
653
|
if inner is None:
|
|
618
654
|
raise ValueError("fallback must be a control created by this Group")
|
|
619
|
-
custom_control =
|
|
655
|
+
custom_control = dataclasses.replace(
|
|
656
|
+
inner, custom_build=build, custom_value_fn=value
|
|
657
|
+
)
|
|
620
658
|
fallback_value = fallback._value
|
|
621
659
|
element = (
|
|
622
660
|
CustomElement(build(fallback_value), fallback, value)
|
|
@@ -896,7 +934,7 @@ class Group:
|
|
|
896
934
|
on_change: typing.Callable[[typing.Any], None] | None,
|
|
897
935
|
) -> typing.Any:
|
|
898
936
|
reset_state = self._value_resolver.query_on_change(input_control, on_change)
|
|
899
|
-
control = input_control.
|
|
937
|
+
control = input_control.make_element(
|
|
900
938
|
self._value_resolver.get_value(input_control, input_control.default),
|
|
901
939
|
label=opt.label_with_tooltip(help_text),
|
|
902
940
|
disabled=self._value_resolver.is_overridden(opt.option) or self._disabled,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import dataclasses
|
|
2
4
|
import html
|
|
3
5
|
import pathlib
|
|
@@ -7,7 +9,6 @@ import typing
|
|
|
7
9
|
import urllib.parse
|
|
8
10
|
|
|
9
11
|
import marimo as mo
|
|
10
|
-
from hypothesis import strategies as st
|
|
11
12
|
|
|
12
13
|
from . import (
|
|
13
14
|
_input_map,
|
|
@@ -23,6 +24,9 @@ from . import (
|
|
|
23
24
|
)
|
|
24
25
|
from .presets import Presets
|
|
25
26
|
|
|
27
|
+
if typing.TYPE_CHECKING:
|
|
28
|
+
from hypothesis import strategies as st
|
|
29
|
+
|
|
26
30
|
|
|
27
31
|
@dataclasses.dataclass
|
|
28
32
|
class Interface:
|
|
@@ -47,6 +51,9 @@ class Interface:
|
|
|
47
51
|
variant_ctx: _variant.VariantContext = dataclasses.field(
|
|
48
52
|
default_factory=_variant.VariantContext
|
|
49
53
|
)
|
|
54
|
+
# Names of defs other than "args" overridden for the embed this interface
|
|
55
|
+
# renders inside; None when that is unknown (not embedded via moops.embed).
|
|
56
|
+
embedded_extra_overrides: frozenset[str] | None = None
|
|
50
57
|
|
|
51
58
|
def __post_init__(self) -> None:
|
|
52
59
|
seen_ids: set[int] = set()
|
|
@@ -250,6 +257,8 @@ class Interface:
|
|
|
250
257
|
return result
|
|
251
258
|
|
|
252
259
|
def strategy(self) -> st.SearchStrategy[dict[str, typing.Any]]:
|
|
260
|
+
from hypothesis import strategies as st
|
|
261
|
+
|
|
253
262
|
strategies: dict[str, st.SearchStrategy[typing.Any]] = {
|
|
254
263
|
name: ctrl_or_sub.strategy() for name, ctrl_or_sub in self.iter_controls()
|
|
255
264
|
}
|
|
@@ -261,8 +270,9 @@ class Interface:
|
|
|
261
270
|
self,
|
|
262
271
|
*,
|
|
263
272
|
active_only: bool,
|
|
264
|
-
root:
|
|
273
|
+
root: Interface | None = None,
|
|
265
274
|
active_args: _parse.ParsedArgs | None = None,
|
|
275
|
+
include_overridden: bool = False,
|
|
266
276
|
) -> typing.Iterator[_options.InputControl]:
|
|
267
277
|
root = self if root is None else root
|
|
268
278
|
if active_only and self._is_inactive(root, active_args=active_args):
|
|
@@ -273,15 +283,18 @@ class Interface:
|
|
|
273
283
|
active_only=active_only,
|
|
274
284
|
root=root,
|
|
275
285
|
active_args=active_args,
|
|
286
|
+
include_overridden=include_overridden,
|
|
276
287
|
)
|
|
277
288
|
else:
|
|
278
289
|
input_control = self.input_map.get(ctrl)
|
|
279
|
-
if input_control is not None and
|
|
290
|
+
if input_control is not None and (
|
|
291
|
+
include_overridden or not self._is_overridden(input_control)
|
|
292
|
+
):
|
|
280
293
|
yield input_control
|
|
281
294
|
|
|
282
295
|
def _is_inactive(
|
|
283
296
|
self,
|
|
284
|
-
root:
|
|
297
|
+
root: Interface,
|
|
285
298
|
*,
|
|
286
299
|
active_args: _parse.ParsedArgs | None = None,
|
|
287
300
|
) -> bool:
|
|
@@ -303,7 +316,7 @@ class Interface:
|
|
|
303
316
|
|
|
304
317
|
def iter_controls(
|
|
305
318
|
self,
|
|
306
|
-
) -> typing.Iterator[tuple[str,
|
|
319
|
+
) -> typing.Iterator[tuple[str, Interface | _options.InputControl]]:
|
|
307
320
|
"""Yield one entry per top-level control, preserving subgroup structure.
|
|
308
321
|
|
|
309
322
|
Yields ``(name, sub_iface)`` for subgroup controls and
|
|
@@ -324,14 +337,16 @@ class Interface:
|
|
|
324
337
|
def _is_overridden(self, input_control: _options.InputControl) -> bool:
|
|
325
338
|
return self._key(input_control) in self.overrides
|
|
326
339
|
|
|
327
|
-
def cur_values(self) -> dict[str, typing.Any]:
|
|
340
|
+
def cur_values(self, *, include_overridden: bool = False) -> dict[str, typing.Any]:
|
|
328
341
|
result: dict[str, typing.Any] = {}
|
|
329
342
|
for ctrl in self.controls:
|
|
330
343
|
if (iface := attached_interface(ctrl)) is not None:
|
|
331
|
-
result.update(iface.cur_values())
|
|
344
|
+
result.update(iface.cur_values(include_overridden=include_overridden))
|
|
332
345
|
else:
|
|
333
346
|
input_control = self.input_map.get(ctrl)
|
|
334
|
-
if input_control is not None and
|
|
347
|
+
if input_control is not None and (
|
|
348
|
+
include_overridden or not self._is_overridden(input_control)
|
|
349
|
+
):
|
|
335
350
|
result[input_control.option] = _marimo_controls.ctrl_value(ctrl)
|
|
336
351
|
return result
|
|
337
352
|
|
|
@@ -350,16 +365,42 @@ class Interface:
|
|
|
350
365
|
wrap per item. Used both for the flat ``_current_args`` string and for
|
|
351
366
|
the line-wrapped command shown in the script callout.
|
|
352
367
|
"""
|
|
353
|
-
|
|
368
|
+
return [" ".join(tokens) for tokens in self._token_groups()]
|
|
369
|
+
|
|
370
|
+
def _token_groups(self, *, include_overridden: bool = False) -> list[list[str]]:
|
|
371
|
+
values = self.cur_values(include_overridden=include_overridden)
|
|
354
372
|
return [
|
|
355
|
-
|
|
356
|
-
for input_control in self._input_controls(
|
|
373
|
+
tokens
|
|
374
|
+
for input_control in self._input_controls(
|
|
375
|
+
active_only=True, include_overridden=include_overridden
|
|
376
|
+
)
|
|
357
377
|
if input_control.option in values
|
|
358
378
|
for tokens in input_control.format_value_groups(
|
|
359
379
|
values[input_control.option]
|
|
360
380
|
)
|
|
361
381
|
]
|
|
362
382
|
|
|
383
|
+
def _standalone_arg_groups(self) -> list[str]:
|
|
384
|
+
"""Current CLI args spelled for running the notebook standalone.
|
|
385
|
+
|
|
386
|
+
Rewrites option tokens to drop this interface's embed prefix. Working
|
|
387
|
+
at the token level covers every control type uniformly, including
|
|
388
|
+
list anchors and ``--no-`` aux flags that embed the prefixed name.
|
|
389
|
+
Overridden controls are included: their values are fixed by the
|
|
390
|
+
embedding parent, so standalone the CLI must pass them explicitly.
|
|
391
|
+
"""
|
|
392
|
+
return [
|
|
393
|
+
" ".join(self._strip_embed_prefix(token) for token in tokens)
|
|
394
|
+
for tokens in self._token_groups(include_overridden=True)
|
|
395
|
+
]
|
|
396
|
+
|
|
397
|
+
def _strip_embed_prefix(self, token: str) -> str:
|
|
398
|
+
stem = self.option_prefix.lstrip("-")
|
|
399
|
+
for head in ("--", "--no-"):
|
|
400
|
+
if token.startswith(f"{head}{stem}-"):
|
|
401
|
+
return f"{head}{token[len(head) + len(stem) + 1 :]}"
|
|
402
|
+
return token
|
|
403
|
+
|
|
363
404
|
def missing_options(self) -> list[str]:
|
|
364
405
|
covered = {
|
|
365
406
|
input_control.option
|
|
@@ -393,10 +434,21 @@ class Interface:
|
|
|
393
434
|
return mo.md("Input bundle with no notebook name")
|
|
394
435
|
notebook_name = html.escape(self.notebook_name)
|
|
395
436
|
href = html.escape(self._standalone_url(), quote=True)
|
|
396
|
-
|
|
437
|
+
link = mo.md(
|
|
397
438
|
f'<a href="{href}" target="_blank" rel="noopener">'
|
|
398
439
|
f"An embedded instance of `{notebook_name}`</a>"
|
|
399
440
|
)
|
|
441
|
+
if self.embedded_extra_overrides != frozenset():
|
|
442
|
+
# None: embedded outside moops.embed, so overrides are unknown.
|
|
443
|
+
# Non-empty: the parent injected defs beyond args, so no CLI
|
|
444
|
+
# command can reproduce this setup.
|
|
445
|
+
return link
|
|
446
|
+
command = _text_wrap.wrap_command(
|
|
447
|
+
self.notebook_file, self._standalone_arg_groups()
|
|
448
|
+
)
|
|
449
|
+
return mo.vstack(
|
|
450
|
+
[link, mo.md(f"To run as a standalone script:\n```\n{command}\n```")]
|
|
451
|
+
)
|
|
400
452
|
|
|
401
453
|
def _standalone_url(self) -> str:
|
|
402
454
|
values = self._standalone_query_values()
|
|
@@ -414,7 +466,7 @@ class Interface:
|
|
|
414
466
|
self._add_query_value(values, ctrl, self.input_map)
|
|
415
467
|
return values
|
|
416
468
|
|
|
417
|
-
def _controls_from_query_values(self, sub_iface:
|
|
469
|
+
def _controls_from_query_values(self, sub_iface: Interface) -> dict[str, str]:
|
|
418
470
|
"""Collect standalone query values for a controls_from mirror.
|
|
419
471
|
|
|
420
472
|
Uses this interface's key scheme (our option_prefix) so the resulting
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|