moops 0.7.0__tar.gz → 0.7.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moops
3
- Version: 0.7.0
3
+ Version: 0.7.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
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "moops"
7
- version = "0.7.0"
7
+ version = "0.7.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"
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import typing
4
+
5
+
6
+ def option_values(
7
+ options: typing.Sequence[typing.Any] | dict[str, typing.Any],
8
+ ) -> dict[str, typing.Any]:
9
+ if isinstance(options, dict):
10
+ return dict(options)
11
+ return {str(opt): opt for opt in options}
12
+
13
+
14
+ def option_key(options: dict[str, typing.Any], value: typing.Any) -> str:
15
+ if isinstance(value, str) and value in options:
16
+ return value
17
+ return next(
18
+ (key for key, option_value in options.items() if value == option_value),
19
+ str(value),
20
+ )
21
+
22
+
23
+ def option_value(options: dict[str, typing.Any], value: typing.Any) -> typing.Any:
24
+ return options[value] if isinstance(value, str) and value in options else value
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import typing
5
+
6
+ import marimo as mo
7
+
8
+ from . import _options, interface
9
+
10
+
11
+ def controls_from(
12
+ group: typing.Any,
13
+ iface: interface.Interface,
14
+ *,
15
+ prefix: str,
16
+ exclude: typing.Iterable[str] = (),
17
+ ) -> mo.ui.dictionary:
18
+ """Create a subgroup of controls mirroring another notebook's interface."""
19
+ child = group.subgroup(prefix)
20
+ excluded = set(exclude)
21
+ controls: dict[str, typing.Any] = {
22
+ name: (
23
+ controls_from(child, ctrl_or_sub, prefix=name)
24
+ if isinstance(ctrl_or_sub, interface.Interface)
25
+ else _create_control(child, iface, ctrl_or_sub)
26
+ )
27
+ for name, ctrl_or_sub in iface.iter_controls()
28
+ if name not in excluded
29
+ }
30
+ result = mo.ui.dictionary(controls)
31
+ # mo.ui.dictionary clones its elements, so result.elements[key] is a
32
+ # different object than controls[key]. Rebind nested dictionary clones
33
+ # to interfaces that track their own live cloned elements.
34
+ for key, original in controls.items():
35
+ _reattach_interface_to_clone(original, result.elements[key])
36
+ # Use the live clones (result.elements) rather than the originals so
37
+ # that cur_values() reads up-to-date widget values.
38
+ result._moops_interface = child.interface(*result.elements.values()) # type: ignore[attr-defined]
39
+ return result
40
+
41
+
42
+ def _create_control(
43
+ group: typing.Any,
44
+ iface: interface.Interface,
45
+ input_control: _options.InputControl,
46
+ ) -> typing.Any:
47
+ return _create_from_input_control(
48
+ group, input_control, _unprefixed_option(iface, input_control.option)
49
+ )
50
+
51
+
52
+ def _create_from_input_control(
53
+ group: typing.Any,
54
+ input_control: _options.InputControl,
55
+ display_option: str,
56
+ ) -> typing.Any:
57
+ """Create a marimo element from an existing InputControl."""
58
+ opt = group._make_opt(label=None, option=display_option)
59
+ cloned = dataclasses.replace(input_control, option=opt.option)
60
+ return group._register_control(opt, cloned, cloned.help_text, None)
61
+
62
+
63
+ def _unprefixed_option(iface: interface.Interface, option: str) -> str:
64
+ if iface.option_prefix and option.startswith(f"{iface.option_prefix}-"):
65
+ return f"--{option[len(iface.option_prefix) :].lstrip('-')}"
66
+ return option
67
+
68
+
69
+ def _reattach_interface_to_clone(original: typing.Any, clone: typing.Any) -> None:
70
+ moops_iface = getattr(original, "_moops_interface", None)
71
+ if not isinstance(moops_iface, interface.Interface):
72
+ return
73
+ original_elements = getattr(original, "elements", None)
74
+ clone_elements = getattr(clone, "elements", None)
75
+ if isinstance(original_elements, dict) and isinstance(clone_elements, dict):
76
+ typed_original_elements = typing.cast(dict[str, typing.Any], original_elements)
77
+ typed_clone_elements = typing.cast(dict[str, typing.Any], clone_elements)
78
+ for key, original_child in typed_original_elements.items():
79
+ if key in typed_clone_elements:
80
+ _reattach_interface_to_clone(original_child, typed_clone_elements[key])
81
+ controls = tuple(typed_clone_elements.values())
82
+ else:
83
+ controls = moops_iface.controls
84
+ moops_iface.controls = controls
85
+ clone._moops_interface = moops_iface
@@ -27,6 +27,10 @@ class OptionLabel:
27
27
  label = option.lstrip("-").replace("-", " ")
28
28
  return OptionLabel(label=label, option=option)
29
29
 
30
+ @property
31
+ def metavar(self) -> str:
32
+ return self.label.upper().replace(" ", "_")
33
+
30
34
  def label_with_tooltip(self, help_text: str) -> str:
31
35
  return (
32
36
  f'<span title="{html.escape(help_text, quote=True)} ({self.option})">'
@@ -10,7 +10,7 @@ import typing
10
10
  import marimo as mo
11
11
  from hypothesis import strategies as st
12
12
 
13
- from . import _parse, _ui_workarounds
13
+ from . import _choice_options, _parse, _ui_workarounds
14
14
 
15
15
  Numeric = int | float
16
16
 
@@ -33,6 +33,7 @@ class InputControl(abc.ABC):
33
33
 
34
34
  option: str
35
35
  help_text: str
36
+ default: typing.Any
36
37
  extra_kwargs: dict[str, typing.Any] = dataclasses.field( # type: ignore[assignment]
37
38
  default_factory=dict, kw_only=True
38
39
  )
@@ -734,7 +735,9 @@ class MultiSelectControl(ValueControl):
734
735
  on_change: typing.Callable[[typing.Any], None] | None = None,
735
736
  disabled: bool = False,
736
737
  ) -> typing.Any:
737
- selected_keys = [_option_key(self.select_opts, item) for item in value]
738
+ selected_keys = [
739
+ _choice_options.option_key(self.select_opts, item) for item in value
740
+ ]
738
741
  if disabled:
739
742
  return _ui_workarounds.LockedMultiselect(
740
743
  [str(item) for item in value], label
@@ -821,7 +824,10 @@ class MultiSelectControl(ValueControl):
821
824
  if self.default:
822
825
  line += (
823
826
  " (default: "
824
- + ", ".join(_option_key(self.select_opts, v) for v in self.default)
827
+ + ", ".join(
828
+ _choice_options.option_key(self.select_opts, v)
829
+ for v in self.default
830
+ )
825
831
  + ")"
826
832
  )
827
833
  line += f" (repeat {self.option} to select multiple)"
@@ -836,21 +842,20 @@ class MultiSelectControl(ValueControl):
836
842
  return []
837
843
  if not values and self._no_flag:
838
844
  return [self._no_flag]
839
- return [
840
- f"{self.option} {shlex.quote(_option_key(self.select_opts, v))}"
841
- for v in values
842
- ]
845
+ return [f"{self.option} {shlex.quote(self._key_for(v))}" for v in values]
843
846
 
844
847
  def format_query_value(self, value: typing.Any) -> str | None:
845
848
  values = list(value)
846
- keys = [_option_key(self.select_opts, v) for v in values]
849
+ keys = [_choice_options.option_key(self.select_opts, v) for v in values]
847
850
  return None if values == self.default else json.dumps(keys)
848
851
 
849
852
  def prompt_interactive(
850
853
  self, effective_default: typing.Any = _UNSET
851
854
  ) -> dict[str, str | None]:
852
855
  d = self.default if effective_default is _UNSET else effective_default
853
- default_keys = {_option_key(self.select_opts, v) for v in (d or [])}
856
+ default_keys = {
857
+ _choice_options.option_key(self.select_opts, v) for v in (d or [])
858
+ }
854
859
  for i, v in enumerate(self.select_opts, 1):
855
860
  mark = "*" if v in default_keys else " "
856
861
  print(f" {mark}{i}) {v}")
@@ -861,6 +866,9 @@ class MultiSelectControl(ValueControl):
861
866
  parts = [p.strip() for p in response.split(",") if p.strip()]
862
867
  return {self.option: "\n".join(parts)}
863
868
 
869
+ def _key_for(self, value: typing.Any) -> str:
870
+ return _choice_options.option_key(self.select_opts, value)
871
+
864
872
 
865
873
  @dataclasses.dataclass
866
874
  class DropdownControl(InputControl):
@@ -1037,14 +1045,5 @@ def _range_stop(
1037
1045
  return max(steps) if steps else stop
1038
1046
 
1039
1047
 
1040
- def _option_key(options: dict[str, typing.Any], value: typing.Any) -> str:
1041
- if isinstance(value, str) and value in options:
1042
- return value
1043
- for key, option_value in options.items():
1044
- if value == option_value:
1045
- return key
1046
- return str(value)
1047
-
1048
-
1049
1048
  def _format_range(value: typing.Iterable[typing.Any]) -> str:
1050
1049
  return ",".join(str(v) for v in value)
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import dataclasses
4
3
  import inspect
5
4
  import pathlib
6
5
  import typing
@@ -9,7 +8,8 @@ import warnings
9
8
  import marimo as mo
10
9
 
11
10
  from . import (
12
- _control_factory,
11
+ _choice_options,
12
+ _control_mirroring,
13
13
  _input_map,
14
14
  _markdown,
15
15
  _naming,
@@ -276,13 +276,7 @@ class Group:
276
276
  widget="switch",
277
277
  extra_kwargs=kwargs,
278
278
  )
279
- return self._input_map.register(
280
- input_control.create_marimo_element(
281
- self._get_value(input_control, value),
282
- **self._control_kwargs(opt, input_control, help_text, on_change),
283
- ),
284
- input_control,
285
- )
279
+ return self._register_control(opt, input_control, help_text, on_change)
286
280
 
287
281
  def checkbox(
288
282
  self,
@@ -304,13 +298,7 @@ class Group:
304
298
  widget="checkbox",
305
299
  extra_kwargs=kwargs,
306
300
  )
307
- return self._input_map.register(
308
- input_control.create_marimo_element(
309
- self._get_value(input_control, value),
310
- **self._control_kwargs(opt, input_control, help_text, on_change),
311
- ),
312
- input_control,
313
- )
301
+ return self._register_control(opt, input_control, help_text, on_change)
314
302
 
315
303
  def text(
316
304
  self,
@@ -335,18 +323,12 @@ class Group:
335
323
  opt = self._make_opt(label=label, option=option)
336
324
  input_control = _options.TextControl(
337
325
  option=opt.option,
338
- metavar=placeholder or opt.label.upper().replace(" ", "_"),
326
+ metavar=placeholder or opt.metavar,
339
327
  help_text=help_text,
340
328
  default=value,
341
329
  extra_kwargs=kwargs,
342
330
  )
343
- return self._input_map.register(
344
- input_control.create_marimo_element(
345
- self._get_value(input_control, value),
346
- **self._control_kwargs(opt, input_control, help_text, on_change),
347
- ),
348
- input_control,
349
- )
331
+ return self._register_control(opt, input_control, help_text, on_change)
350
332
 
351
333
  def text_area(
352
334
  self,
@@ -366,18 +348,12 @@ class Group:
366
348
  opt = self._make_opt(label=label, option=option)
367
349
  input_control = _options.TextAreaControl(
368
350
  option=opt.option,
369
- metavar=placeholder or opt.label.upper().replace(" ", "_"),
351
+ metavar=placeholder or opt.metavar,
370
352
  help_text=help_text,
371
353
  default=value,
372
354
  extra_kwargs=kwargs,
373
355
  )
374
- return self._input_map.register(
375
- input_control.create_marimo_element(
376
- self._get_value(input_control, value),
377
- **self._control_kwargs(opt, input_control, help_text, on_change),
378
- ),
379
- input_control,
380
- )
356
+ return self._register_control(opt, input_control, help_text, on_change)
381
357
 
382
358
  def file_browser(
383
359
  self,
@@ -423,13 +399,7 @@ class Group:
423
399
  help_text=help_text,
424
400
  extra_kwargs=kwargs,
425
401
  )
426
- return self._input_map.register(
427
- input_control.create_marimo_element(
428
- self._get_value(input_control, default),
429
- **self._control_kwargs(opt, input_control, help_text, on_change),
430
- ),
431
- input_control,
432
- )
402
+ return self._register_control(opt, input_control, help_text, on_change)
433
403
 
434
404
  def number(
435
405
  self,
@@ -448,15 +418,10 @@ class Group:
448
418
  """Create a number input UI element that maps to a CLI option."""
449
419
 
450
420
  kwargs = {"step": step, "debounce": debounce, **kwargs}
451
- opt, input_control, value = self._numeric_input_control(
421
+ opt, input_control = self._numeric_input_control(
452
422
  start, stop, value, option, help_text, label, "number", kwargs
453
423
  )
454
- return self._input_map.register(
455
- input_control.create_marimo_element(
456
- value, **self._control_kwargs(opt, input_control, help_text, on_change)
457
- ),
458
- input_control,
459
- )
424
+ return self._register_control(opt, input_control, help_text, on_change)
460
425
 
461
426
  def slider(
462
427
  self,
@@ -475,15 +440,10 @@ class Group:
475
440
  """Create a slider UI element that maps to a CLI option."""
476
441
 
477
442
  kwargs = {"step": step, "debounce": debounce, **kwargs}
478
- opt, input_control, value = self._numeric_input_control(
443
+ opt, input_control = self._numeric_input_control(
479
444
  start, stop, value, option, help_text, label, "slider", kwargs
480
445
  )
481
- return self._input_map.register(
482
- input_control.create_marimo_element(
483
- value, **self._control_kwargs(opt, input_control, help_text, on_change)
484
- ),
485
- input_control,
486
- )
446
+ return self._register_control(opt, input_control, help_text, on_change)
487
447
 
488
448
  def range_slider(
489
449
  self,
@@ -513,7 +473,7 @@ class Group:
513
473
  opt = self._make_opt(label=label, option=option)
514
474
  input_control = _options.RangeControl.from_slider(
515
475
  option=opt.option,
516
- metavar=opt.label.upper().replace(" ", "_"),
476
+ metavar=opt.metavar,
517
477
  help_text=help_text,
518
478
  start=start,
519
479
  stop=stop,
@@ -522,13 +482,7 @@ class Group:
522
482
  step=step,
523
483
  extra_kwargs=kwargs,
524
484
  )
525
- return self._input_map.register(
526
- input_control.create_marimo_element(
527
- self._get_value(input_control, input_control.default),
528
- **self._control_kwargs(opt, input_control, help_text, on_change),
529
- ),
530
- input_control,
531
- )
485
+ return self._register_control(opt, input_control, help_text, on_change)
532
486
 
533
487
  def custom(
534
488
  self,
@@ -582,13 +536,13 @@ class Group:
582
536
  label: str | None,
583
537
  widget: typing.Literal["number", "slider"] = "number",
584
538
  extra_kwargs: dict[str, typing.Any] | None = None,
585
- ) -> tuple[_naming.OptionLabel, _options.NumberControl, float | None]:
539
+ ) -> tuple[_naming.OptionLabel, _options.NumberControl]:
586
540
  if value is None:
587
541
  value = start
588
542
  opt = self._make_opt(label=label, option=option)
589
543
  input_control = _options.NumberControl(
590
544
  option=opt.option,
591
- metavar=opt.label.upper().replace(" ", "_"),
545
+ metavar=opt.metavar,
592
546
  help_text=help_text,
593
547
  default=value,
594
548
  start=start,
@@ -596,7 +550,7 @@ class Group:
596
550
  widget=widget,
597
551
  extra_kwargs=extra_kwargs or {},
598
552
  )
599
- return opt, input_control, self._get_value(input_control, value)
553
+ return opt, input_control
600
554
 
601
555
  def dropdown(
602
556
  self,
@@ -618,8 +572,12 @@ class Group:
618
572
 
619
573
  assert len(options) > 0, "Dropdown options cannot be empty"
620
574
  opt = self._make_opt(label=label, option=option)
621
- dropdown_opts = _option_values(options)
622
- value = _option_key(dropdown_opts, value) if value is not None else None
575
+ dropdown_opts = _choice_options.option_values(options)
576
+ value = (
577
+ _choice_options.option_key(dropdown_opts, value)
578
+ if value is not None
579
+ else None
580
+ )
623
581
  if value is None and not allow_select_none:
624
582
  value, *_ = [*dropdown_opts]
625
583
  input_control = _options.DropdownControl(
@@ -634,13 +592,7 @@ class Group:
634
592
  **kwargs,
635
593
  },
636
594
  )
637
- return self._input_map.register(
638
- input_control.create_marimo_element(
639
- self._get_value(input_control, value),
640
- **self._control_kwargs(opt, input_control, help_text, on_change),
641
- ),
642
- input_control,
643
- )
595
+ return self._register_control(opt, input_control, help_text, on_change)
644
596
 
645
597
  def multiselect(
646
598
  self,
@@ -657,23 +609,17 @@ class Group:
657
609
  if value is None:
658
610
  value = []
659
611
  opt = self._make_opt(label=label, option=option)
660
- select_opts = _option_values(options)
661
- default = [_option_value(select_opts, item) for item in value]
612
+ select_opts = _choice_options.option_values(options)
613
+ default = [_choice_options.option_value(select_opts, item) for item in value]
662
614
  input_control = _options.MultiSelectControl(
663
615
  option=opt.option,
664
- metavar=opt.label.upper().replace(" ", "_"),
616
+ metavar=opt.metavar,
665
617
  help_text=help_text,
666
618
  default=default,
667
619
  select_opts=select_opts,
668
620
  extra_kwargs=kwargs,
669
621
  )
670
- return self._input_map.register(
671
- input_control.create_marimo_element(
672
- self._get_value(input_control, default),
673
- **self._control_kwargs(opt, input_control, help_text, on_change),
674
- ),
675
- input_control,
676
- )
622
+ return self._register_control(opt, input_control, help_text, on_change)
677
623
 
678
624
  def controls_from(
679
625
  self,
@@ -689,27 +635,9 @@ class Group:
689
635
  The controls themselves are created in a subgroup, so their CLI options
690
636
  are prefixed in the parent notebook.
691
637
  """
692
- child = self.subgroup(prefix)
693
- excluded = set(exclude)
694
- controls: dict[str, typing.Any] = {
695
- name: (
696
- child.controls_from(ctrl_or_sub, prefix=name)
697
- if isinstance(ctrl_or_sub, interface.Interface)
698
- else _control_factory.create_control(child, iface, ctrl_or_sub)
699
- )
700
- for name, ctrl_or_sub in iface.iter_controls()
701
- if name not in excluded
702
- }
703
- result = mo.ui.dictionary(controls)
704
- # mo.ui.dictionary clones its elements, so result.elements[key] is a
705
- # different object than controls[key]. Rebind nested dictionary clones
706
- # to interfaces that track their own live cloned elements.
707
- for key, original in controls.items():
708
- _reattach_interface_to_clone(original, result.elements[key])
709
- # Use the live clones (result.elements) rather than the originals so
710
- # that cur_values() reads up-to-date widget values.
711
- result._moops_interface = child.interface(*result.elements.values()) # type: ignore[attr-defined]
712
- return result
638
+ return _control_mirroring.controls_from(
639
+ self, iface, prefix=prefix, exclude=exclude
640
+ )
713
641
 
714
642
  def _make_value_resolver(self) -> _value_resolution.ValueResolver:
715
643
  return _value_resolution.ValueResolver(
@@ -721,49 +649,24 @@ class Group:
721
649
  default_preset_state=self._preset_state.default,
722
650
  )
723
651
 
724
- def _control_kwargs(
652
+ def _register_control(
725
653
  self,
726
654
  opt: _naming.OptionLabel,
727
655
  input_control: _options.InputControl,
728
656
  help_text: str,
729
657
  on_change: typing.Callable[[typing.Any], None] | None,
730
- ) -> dict[str, typing.Any]:
731
- return {
732
- "label": opt.label_with_tooltip(help_text),
733
- "disabled": self._is_overridden(opt.option) or self._disabled,
734
- "on_change": self._query_on_change(input_control, on_change),
735
- }
736
-
737
- def _get_value(
738
- self,
739
- control: _options.InputControl,
740
- default: typing.Any,
741
658
  ) -> typing.Any:
742
- return self._value_resolver.get_value(control, default)
743
-
744
- def _is_overridden(self, option: str) -> bool:
745
- return self._value_resolver.is_overridden(option)
746
-
747
- def _query_on_change(
748
- self,
749
- control: _options.InputControl,
750
- on_change: typing.Callable[[typing.Any], None] | None,
751
- ) -> typing.Callable[[typing.Any], None] | None:
752
- return self._value_resolver.query_on_change(control, on_change)
753
-
754
- def _create_from_input_control(
755
- self,
756
- input_control: _options.InputControl,
757
- display_option: str,
758
- ) -> typing.Any:
759
- """Create a marimo element from an existing InputControl."""
760
- opt = self._make_opt(label=None, option=display_option)
761
- cloned = dataclasses.replace(input_control, option=opt.option)
762
- value = self._get_value(cloned, getattr(cloned, "default", None))
763
- ctrl_kwargs = self._control_kwargs(opt, cloned, cloned.help_text, None)
764
659
  return self._input_map.register(
765
- cloned.create_marimo_element(value, **ctrl_kwargs),
766
- cloned,
660
+ input_control.create_marimo_element(
661
+ self._value_resolver.get_value(input_control, input_control.default),
662
+ label=opt.label_with_tooltip(help_text),
663
+ disabled=self._value_resolver.is_overridden(opt.option)
664
+ or self._disabled,
665
+ on_change=self._value_resolver.query_on_change(
666
+ input_control, on_change
667
+ ),
668
+ ),
669
+ input_control,
767
670
  )
768
671
 
769
672
  def _make_opt(
@@ -776,43 +679,3 @@ class Group:
776
679
  option=f"{self.option}-{opt.option.lstrip('-')}",
777
680
  )
778
681
  return opt
779
-
780
-
781
- def _option_values(
782
- options: typing.Sequence[typing.Any] | dict[str, typing.Any],
783
- ) -> dict[str, typing.Any]:
784
- if isinstance(options, dict):
785
- return dict(options)
786
- return {str(opt): opt for opt in options}
787
-
788
-
789
- def _reattach_interface_to_clone(original: typing.Any, clone: typing.Any) -> None:
790
- moops_iface = getattr(original, "_moops_interface", None)
791
- if not isinstance(moops_iface, interface.Interface):
792
- return
793
- original_elements = getattr(original, "elements", None)
794
- clone_elements = getattr(clone, "elements", None)
795
- if isinstance(original_elements, dict) and isinstance(clone_elements, dict):
796
- typed_original_elements = typing.cast(dict[str, typing.Any], original_elements)
797
- typed_clone_elements = typing.cast(dict[str, typing.Any], clone_elements)
798
- for key, original_child in typed_original_elements.items():
799
- if key in typed_clone_elements:
800
- _reattach_interface_to_clone(original_child, typed_clone_elements[key])
801
- controls = tuple(typed_clone_elements.values())
802
- else:
803
- controls = moops_iface.controls
804
- moops_iface.controls = controls
805
- clone._moops_interface = moops_iface
806
-
807
-
808
- def _option_key(options: dict[str, typing.Any], value: typing.Any) -> str:
809
- if isinstance(value, str) and value in options:
810
- return value
811
- return next(
812
- (key for key, option_value in options.items() if value == option_value),
813
- str(value),
814
- )
815
-
816
-
817
- def _option_value(options: dict[str, typing.Any], value: typing.Any) -> typing.Any:
818
- return options[value] if isinstance(value, str) and value in options else value
@@ -13,6 +13,47 @@ from . import _input_map, _options, _parse, _query_params
13
13
  from .presets import Presets
14
14
 
15
15
 
16
+ def _wrap_usage(prefix: str, parts: list[str], width: int = 88) -> str:
17
+ indent = " " * len(prefix)
18
+ lines: list[str] = []
19
+ current = prefix
20
+ first_on_line = True
21
+ for part in parts:
22
+ attempt = current + part if first_on_line else current + " " + part
23
+ if first_on_line or len(attempt) <= width:
24
+ current = attempt
25
+ first_on_line = False
26
+ else:
27
+ lines.append(current)
28
+ current = indent + part
29
+ lines.append(current)
30
+ return "\n".join(lines)
31
+
32
+
33
+ def _wrap_help_line(line: str, width: int = 88) -> list[str]:
34
+ if len(line) <= width:
35
+ return [line]
36
+ sep = ": "
37
+ sep_idx = line.find(sep)
38
+ if sep_idx == -1:
39
+ return [line]
40
+ header = line[: sep_idx + 1] # e.g. " --option METAVAR:"
41
+ indent = " "
42
+ result = [header]
43
+ current = indent
44
+ first_on_line = True
45
+ for word in line[sep_idx + len(sep) :].split():
46
+ attempt = current + word if first_on_line else current + " " + word
47
+ if first_on_line or len(attempt) <= width:
48
+ current = attempt
49
+ first_on_line = False
50
+ else:
51
+ result.append(current)
52
+ current = indent + word
53
+ result.append(current)
54
+ return result
55
+
56
+
16
57
  @dataclasses.dataclass
17
58
  class Interface:
18
59
  """Controls registered by a subgroup's interface, for passing to the parent."""
@@ -56,7 +97,7 @@ class Interface:
56
97
 
57
98
  def has_prefixed_options(self, state: _parse.ParseState) -> bool:
58
99
  """True if state has CLI options starting with this interface's prefix."""
59
- prefix = self.option_prefix + "-" if self.option_prefix else "--"
100
+ prefix = f"{self.option_prefix}-" if self.option_prefix else "--"
60
101
  return any(
61
102
  k
62
103
  for k in state.args.options
@@ -95,33 +136,38 @@ class Interface:
95
136
  usage_parts = list(self._format_usage_parts(_usage_placeholders(self)))
96
137
  usage_parts.extend(("[--interactive]", "[-h/--help]"))
97
138
  name = self.command.rsplit("/", 1)[-1]
98
- segments = [f"Usage: {name} {' '.join(usage_parts)}"]
139
+ prefix = f"Usage: {name} "
140
+ segments = [_wrap_usage(prefix, usage_parts)]
99
141
  help_lines = list(self._format_help_lines())
100
142
  if help_lines:
101
143
  segments.append("\n".join(help_lines))
102
144
  return "\n\n".join(segments)
103
145
 
104
146
  def _format_help_lines(self) -> typing.Iterator[str]:
147
+ prev_was_group_with_content = False
105
148
  for ctrl in self.controls:
106
149
  if (sub_iface := _attached_interface(ctrl)) is not None:
107
150
  lines = list(sub_iface._format_help_lines())
108
151
  if lines and sub_iface.help_heading:
109
152
  yield ""
110
- yield sub_iface.help_heading + ":"
153
+ yield f"{sub_iface.help_heading}:"
111
154
  yield from lines
155
+ prev_was_group_with_content = bool(lines)
112
156
  else:
113
157
  input_control = self.input_map.get(ctrl)
114
158
  if input_control is not None and not self._is_overridden(input_control):
115
- yield from input_control.format_help_lines()
159
+ if prev_was_group_with_content:
160
+ yield ""
161
+ for help_line in input_control.format_help_lines():
162
+ yield from _wrap_help_line(help_line)
163
+ prev_was_group_with_content = False
116
164
 
117
165
  def _format_usage_parts(
118
166
  self, placeholders_by_option: dict[str, str]
119
167
  ) -> typing.Iterator[str]:
120
168
  for ctrl in self.controls:
121
- if (sub_iface := _attached_interface(ctrl)) is not None:
122
- if not sub_iface.usage_placeholder:
123
- yield from sub_iface._format_usage_parts(placeholders_by_option)
124
- else:
169
+ sub_iface = _attached_interface(ctrl)
170
+ if sub_iface is None:
125
171
  input_control = self.input_map.get(ctrl)
126
172
  if input_control is not None and not self._is_overridden(input_control):
127
173
  yield from input_control.format_usage_parts()
@@ -129,14 +175,16 @@ class Interface:
129
175
  if placeholder := placeholders_by_option.pop(option, None):
130
176
  yield placeholder
131
177
 
178
+ elif not sub_iface.usage_placeholder:
179
+ yield from sub_iface._format_usage_parts(placeholders_by_option)
180
+
132
181
  @property
133
182
  def default(self) -> dict[str, typing.Any]:
134
- result: dict[str, typing.Any] = {}
135
- for name, ctrl_or_sub in self.iter_controls():
136
- if isinstance(ctrl_or_sub, Interface):
137
- result[name] = ctrl_or_sub.default
138
- elif hasattr(ctrl_or_sub, "default"):
139
- result[name] = ctrl_or_sub.default # type: ignore
183
+ result: dict[str, typing.Any] = {
184
+ name: ctrl_or_sub.default # type: ignore
185
+ for name, ctrl_or_sub in self.iter_controls()
186
+ if isinstance(ctrl_or_sub, Interface) or hasattr(ctrl_or_sub, "default")
187
+ }
140
188
  return result
141
189
 
142
190
  def strategy(self) -> st.SearchStrategy[dict[str, typing.Any]]:
@@ -312,9 +360,9 @@ class Interface:
312
360
  items: list[typing.Any] = [
313
361
  mo.callout(
314
362
  mo.md(
315
- f"This notebook also works as a script:\n```\n{self.help()}\n```\n"
316
- "To run the script with the current values in the notebook use:\n"
317
- f"```\n{current_command}\n```"
363
+ "This notebook also works as a script:\n\n"
364
+ f"```\n{current_command}\n```\n\n"
365
+ f"<details><summary>Usage</summary>\n\n```\n{self.help()}\n```\n</details>\n\n"
318
366
  f"{missing_options_msg}"
319
367
  ),
320
368
  "warn" if missing_options else "info",
@@ -1,19 +0,0 @@
1
- import typing
2
-
3
- from . import _options, interface
4
-
5
-
6
- def create_control(
7
- group: typing.Any,
8
- iface: interface.Interface,
9
- input_control: _options.InputControl,
10
- ) -> typing.Any:
11
- return group._create_from_input_control(
12
- input_control, _unprefixed_option(iface, input_control.option)
13
- )
14
-
15
-
16
- def _unprefixed_option(iface: interface.Interface, option: str) -> str:
17
- if iface.option_prefix and option.startswith(f"{iface.option_prefix}-"):
18
- return f"--{option[len(iface.option_prefix) :].lstrip('-')}"
19
- return option
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