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.
Files changed (32) hide show
  1. {moops-0.12.0 → moops-0.12.2}/PKG-INFO +5 -2
  2. {moops-0.12.0 → moops-0.12.2}/README.md +2 -0
  3. {moops-0.12.0 → moops-0.12.2}/pyproject.toml +6 -2
  4. {moops-0.12.0 → moops-0.12.2}/src/moops/_control_mirroring.py +5 -26
  5. {moops-0.12.0 → moops-0.12.2}/src/moops/_custom_element.py +3 -12
  6. {moops-0.12.0 → moops-0.12.2}/src/moops/_embed.py +30 -7
  7. {moops-0.12.0 → moops-0.12.2}/src/moops/_list_options.py +66 -57
  8. {moops-0.12.0 → moops-0.12.2}/src/moops/_options.py +81 -118
  9. {moops-0.12.0 → moops-0.12.2}/src/moops/_query_params.py +5 -12
  10. {moops-0.12.0 → moops-0.12.2}/src/moops/_terminal_graphics.py +9 -19
  11. {moops-0.12.0 → moops-0.12.2}/src/moops/_ui_workarounds.py +0 -1
  12. {moops-0.12.0 → moops-0.12.2}/src/moops/_value_resolution.py +29 -2
  13. {moops-0.12.0 → moops-0.12.2}/src/moops/group.py +41 -3
  14. {moops-0.12.0 → moops-0.12.2}/src/moops/interface.py +65 -13
  15. {moops-0.12.0 → moops-0.12.2}/src/moops/__init__.py +0 -0
  16. {moops-0.12.0 → moops-0.12.2}/src/moops/_choice_options.py +0 -0
  17. {moops-0.12.0 → moops-0.12.2}/src/moops/_input_map.py +0 -0
  18. {moops-0.12.0 → moops-0.12.2}/src/moops/_interface_utils.py +0 -0
  19. {moops-0.12.0 → moops-0.12.2}/src/moops/_list_controls.py +0 -0
  20. {moops-0.12.0 → moops-0.12.2}/src/moops/_marimo_controls.py +0 -0
  21. {moops-0.12.0 → moops-0.12.2}/src/moops/_markdown.py +0 -0
  22. {moops-0.12.0 → moops-0.12.2}/src/moops/_naming.py +0 -0
  23. {moops-0.12.0 → moops-0.12.2}/src/moops/_parse.py +0 -0
  24. {moops-0.12.0 → moops-0.12.2}/src/moops/_preset_state.py +0 -0
  25. {moops-0.12.0 → moops-0.12.2}/src/moops/_presets_ui.py +0 -0
  26. {moops-0.12.0 → moops-0.12.2}/src/moops/_run.py +0 -0
  27. {moops-0.12.0 → moops-0.12.2}/src/moops/_run_button.py +0 -0
  28. {moops-0.12.0 → moops-0.12.2}/src/moops/_subgroup_registry.py +0 -0
  29. {moops-0.12.0 → moops-0.12.2}/src/moops/_text_wrap.py +0 -0
  30. {moops-0.12.0 → moops-0.12.2}/src/moops/_variant.py +0 -0
  31. {moops-0.12.0 → moops-0.12.2}/src/moops/presets.py +0 -0
  32. {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.0
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.0"
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 = _attached_interface(element)
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 _attached_interface(element) is not None:
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 = _selected_value_for_option(root_iface, selector_option)
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 = _attached_interface(element)
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 _default_custom_value(
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 _default_custom_value
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 and rendered outputs can be released
103
- after the embed completes. Pass additional definition names in ``keep`` to
104
- retain them as well (in notebook mode all definitions are exposed and
105
- ``keep`` has no effect).
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, defs: dict[str, typing.Any], keep: tuple[str, ...] = ()
230
+ app: _App,
231
+ defs: dict[str, typing.Any],
232
+ keep: tuple[str, ...] = (),
211
233
  ) -> typing.Any:
212
- _, computed_defs = app.run(defs=defs)
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
- owner: _ListUIOwner,
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 = owner.current_items()
97
- owner.set_items([*current[:idx], owner.default_item(), *current[idx:]])
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 = owner.current_items()
80
+ current = current_items()
101
81
  if 0 <= idx < len(current):
102
- owner.set_items([*current[:idx], *current[idx + 1 :]])
82
+ set_items([*current[:idx], *current[idx + 1 :]])
103
83
 
104
84
  def move(idx: int, delta: int) -> None:
105
- current = owner.current_items()
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
- owner.set_items(reordered)
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(owner.current_items())),
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
- try:
508
- return json.dumps(value)
509
- except TypeError:
510
- return None
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
- raw_items: typing.Any = json.loads(value)
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(raw_items, list):
512
+ if not isinstance(raw, list):
518
513
  return ParseError(f"Query parameter for {self.option} must be a JSON list")
519
- return ParseResult(raw_items)
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
- owner=_CallbackListUIOwner(
556
- value_getter=value_getter,
557
- make_default=lambda: copy.deepcopy(self.item_template_default),
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.create_marimo_element(
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
- owner=_CallbackListUIOwner(
846
- value_getter=value_getter,
847
- make_default=lambda: copy.deepcopy(self.item_control.default),
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( # type: ignore[assignment]
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) -> "InputControl":
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) if v and not pathlib.Path(v).exists():
411
- # Empty string means no file selected; skip existence check.
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 v and not pathlib.Path(v).exists():
424
- print(f"File not found: {v!r}")
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 value and not pathlib.Path(value).exists():
471
- return ParseError(f"File not found: {value!r}")
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 item and not pathlib.Path(item).exists():
488
- return ParseError(f"File not found: {item!r}")
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
- missing = [path for path in paths if not pathlib.Path(path).exists()]
526
- if missing:
527
- print(f"File not found: {missing[0]!r}")
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
- ) -> "RangeControl":
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=_empty_cli_opts)
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: QueryParamStore | None
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
- typing.cast(typing.MutableMapping[str, typing.Any], params).pop(
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
- ) -> Protocol:
21
+ ) -> bool:
30
22
  """Best-effort detection of inline-image support for ``stream``.
31
23
 
32
- Returns ``Protocol.NONE`` whenever stdout is not a terminal (piped or
33
- redirected), so escape sequences never leak into captured output. Terminal
34
- identification is by environment variable; this covers kitty, Ghostty,
35
- WezTerm, and Konsole, all of which speak the Kitty graphics protocol.
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 Protocol.NONE
32
+ return False
41
33
  term = env.get("TERM", "")
42
- if (
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) is Protocol.KITTY:
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 query_value
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
- return _terminal_graphics.detect() is not _terminal_graphics.Protocol.NONE
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 = _options.CustomControl.wrap(inner, build, value)
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.create_marimo_element(
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: "Interface | None" = None,
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 not self._is_overridden(input_control):
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: "Interface",
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, "Interface | _options.InputControl"]]:
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 not self._is_overridden(input_control):
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
- values = self.cur_values()
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
- " ".join(tokens)
356
- for input_control in self._input_controls(active_only=True)
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
- return mo.md(
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: "Interface") -> dict[str, str]:
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