moops 0.3.1__tar.gz → 0.3.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.3.1
3
+ Version: 0.3.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.3.1"
7
+ version = "0.3.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"
@@ -40,6 +40,7 @@ dev-dependencies = [
40
40
  "pytest>=9.0.3",
41
41
  "vulture>=2.16",
42
42
  "pymarkdownlnt>=0.9.37",
43
+ "matplotlib>=3.10.9",
43
44
  ]
44
45
 
45
46
  [tool.pytest.ini_options]
@@ -1,10 +1,11 @@
1
1
  from importlib.metadata import version
2
2
 
3
3
  from ._run import run
4
+ from ._run_button import run_button
4
5
  from .group import Group
5
6
  from .interface import Interface
6
7
  from .presets import Presets
7
8
 
8
9
  __version__ = version("moops")
9
10
 
10
- __all__ = ["Group", "Interface", "Presets", "__version__", "run"]
11
+ __all__ = ["Group", "Interface", "Presets", "__version__", "run", "run_button"]
@@ -36,7 +36,7 @@ class InputControl(abc.ABC):
36
36
 
37
37
  def options(self) -> set[str]:
38
38
  """Value options for this control."""
39
- return set()
39
+ return {self.option}
40
40
 
41
41
  def flags(self) -> set[str]:
42
42
  """Flags for this control."""
@@ -98,6 +98,9 @@ class InputControl(abc.ABC):
98
98
  class FlagControl(InputControl):
99
99
  default: bool = False
100
100
 
101
+ def options(self) -> set[str]:
102
+ return set()
103
+
101
104
  def flags(self) -> set[str]:
102
105
  return {self.option}
103
106
 
@@ -157,9 +160,6 @@ class ValueControl(InputControl):
157
160
 
158
161
  metavar: str
159
162
 
160
- def options(self) -> set[str]:
161
- return {self.option}
162
-
163
163
  def format_usage_parts(self) -> list[str]:
164
164
  return [f"[{self.option} {self.metavar}]"]
165
165
 
@@ -536,13 +536,10 @@ class RangeControl(ValueControl):
536
536
 
537
537
  @dataclasses.dataclass
538
538
  class DropdownControl(InputControl):
539
- allowed_values: list[str]
539
+ dropdown_opts: dict[str, typing.Any]
540
540
  supports_none: bool
541
541
  default: str | None
542
542
 
543
- def options(self) -> set[str]:
544
- return {self.option}
545
-
546
543
  @property
547
544
  def has_no_flag(self) -> bool:
548
545
  return self.supports_none and self.default is not None
@@ -564,26 +561,28 @@ class DropdownControl(InputControl):
564
561
  raw = args.value_for(self.option)
565
562
  if raw is None:
566
563
  return None
567
- if raw not in self.allowed_values:
564
+ if raw not in self.dropdown_opts:
568
565
  return ParseError(
569
566
  f"Option {self.option} must be one of"
570
- f" {self.allowed_values!r}, got: {raw!r}"
567
+ f" {list(self.dropdown_opts)!r}, got: {raw!r}"
571
568
  )
572
569
  return ParseResult(raw)
573
570
 
574
571
  def parse_query_value(self, value: str) -> ParseResult | ParseError:
575
572
  if not value and self.supports_none:
576
573
  return ParseResult(None)
577
- if value not in self.allowed_values:
574
+ if value not in self.dropdown_opts:
578
575
  return ParseError(
579
576
  f"Query parameter for {self.option} must be one of"
580
- f" {self.allowed_values!r}, got: {value!r}"
577
+ f" {list(self.dropdown_opts)!r}, got: {value!r}"
581
578
  )
582
579
  return ParseResult(value)
583
580
 
584
581
  def strategy(self) -> st.SearchStrategy:
585
582
  return st.sampled_from(
586
- [None, *self.allowed_values] if self.supports_none else self.allowed_values
583
+ [None, *self.dropdown_opts.keys()]
584
+ if self.supports_none
585
+ else list(self.dropdown_opts.keys())
587
586
  )
588
587
 
589
588
  def format_usage_parts(self) -> list[str]:
@@ -592,7 +591,7 @@ class DropdownControl(InputControl):
592
591
  return [f"[{self.option} {self._values_text()}]"]
593
592
 
594
593
  def _values_text(self) -> str:
595
- return "{" + "|".join(self.allowed_values) + "}"
594
+ return "{" + "|".join(self.dropdown_opts) + "}"
596
595
 
597
596
  def format_help_lines(self) -> list[str]:
598
597
  line = f" {self.option} {self._values_text()}: {self.help_text}"
@@ -614,13 +613,16 @@ class DropdownControl(InputControl):
614
613
  def format_query_value(self, value: typing.Any) -> str | None:
615
614
  if value == self.default:
616
615
  return None
617
- return "" if value is None else str(value)
616
+ return next(
617
+ (k for k, v in self.dropdown_opts.items() if v == value),
618
+ "" if value is None else str(value),
619
+ )
618
620
 
619
621
  def prompt_interactive(
620
622
  self, effective_default: typing.Any = _UNSET
621
623
  ) -> dict[str, str | None]:
622
624
  d = self.default if effective_default is _UNSET else effective_default
623
- choices = (["none"] if self.supports_none else []) + self.allowed_values
625
+ choices = [*(["none"] if self.supports_none else []), *self.dropdown_opts]
624
626
  for i, v in enumerate(choices, 1):
625
627
  print(f" {i}) {v}")
626
628
  default_display = f" [{d if d is not None else 'none'}]"
@@ -634,12 +636,12 @@ class DropdownControl(InputControl):
634
636
  idx = int(response) - 1
635
637
  select_none = self.supports_none and idx == 0
636
638
  chosen = choices[idx]
637
- elif response in self.allowed_values:
639
+ elif response in self.dropdown_opts:
638
640
  chosen = response
639
641
  elif (
640
642
  response.lower() == "none"
641
643
  and self.supports_none
642
- and "none" not in self.allowed_values
644
+ and "none" not in self.dropdown_opts
643
645
  ):
644
646
  select_none = True
645
647
  else:
@@ -80,9 +80,7 @@ class QueryParams:
80
80
  def _is_user_key(self, key: str) -> bool:
81
81
  if key == "file":
82
82
  return False
83
- if not self.prefix:
84
- return True
85
- return key.startswith(f"{self.prefix}.")
83
+ return not self.prefix or key.startswith(f"{self.prefix}.")
86
84
 
87
85
  def _set(self, key: str, value: str | None) -> None:
88
86
  params = self.params
@@ -0,0 +1,20 @@
1
+ import typing
2
+
3
+ import marimo as mo
4
+
5
+
6
+ class _RunButtonStub:
7
+ """Returned by run_button() outside notebooks; always considered clicked."""
8
+
9
+ value: bool = True
10
+
11
+
12
+ def run_button(**kwargs: typing.Any) -> mo.ui.run_button | _RunButtonStub:
13
+ """Create a run button that gates notebook execution.
14
+
15
+ In CLI context, always returns a stub with .value = True so code that
16
+ checks `mo.stop(not btn.value)` runs unconditionally.
17
+ """
18
+ if mo.running_in_notebook():
19
+ return mo.ui.run_button(**kwargs)
20
+ return _RunButtonStub()
@@ -11,6 +11,7 @@ import weakref
11
11
  import marimo as mo
12
12
 
13
13
  from . import _input_map, _markdown, _naming, _options, _parse, _query_params, interface
14
+ from ._run_button import run_button
14
15
  from .interface import FileBrowserWithInitialSelection
15
16
  from .presets import Presets
16
17
 
@@ -76,6 +77,21 @@ class Group:
76
77
  """
77
78
  if markdown_heading_offset < 0:
78
79
  raise ValueError("markdown_heading_offset must be non-negative")
80
+ _frame = inspect.currentframe()
81
+ _caller = _frame.f_back if _frame else None
82
+ if (
83
+ _caller is not None
84
+ and mo.running_in_notebook()
85
+ and bool(_caller.f_code.co_flags & inspect.CO_COROUTINE)
86
+ ):
87
+ warnings.warn(
88
+ f"args.subgroup('{prefix}') called inside an async cell, "
89
+ "likely the cell making the embed it is used for. "
90
+ "Each cell re-run creates a new Group object, "
91
+ "which causes the embedded notebook to reload and lose widget state. "
92
+ "Move args.subgroup() to a separate sync cell instead.",
93
+ stacklevel=2,
94
+ )
79
95
  child = type(self)([prefix])
80
96
  child._state = self._state
81
97
  child._cli_map = _input_map.InputMap()
@@ -509,6 +525,17 @@ class Group:
509
525
  )
510
526
  return self._cli_map.register(wrapped, cli)
511
527
 
528
+ @staticmethod
529
+ def run_button(
530
+ **kwargs: typing.Any,
531
+ ):
532
+ """Create a run button that gates notebook execution.
533
+
534
+ In CLI context, always returns a stub with .value = True so code that
535
+ checks `mo.stop(not btn.value)` runs unconditionally.
536
+ """
537
+ return run_button(**kwargs)
538
+
512
539
  def _numeric_cli(
513
540
  self,
514
541
  start: float | None,
@@ -544,20 +571,22 @@ class Group:
544
571
 
545
572
  assert len(options) > 0, "Dropdown options cannot be empty"
546
573
  opt = self._make_opt(label=label, option=option)
547
- keys = list(options)
548
574
  if value is None and not allow_select_none:
549
- value, *_ = keys
575
+ value, *_ = [*options]
550
576
  cli = _options.DropdownControl(
551
577
  option=opt.option,
552
- allowed_values=keys,
578
+ dropdown_opts=options
579
+ if isinstance(options, dict)
580
+ else {opt: opt for opt in options},
553
581
  supports_none=allow_select_none,
554
582
  default=value,
555
583
  help_text=help_text,
556
584
  )
557
585
  if self._is_overridden(opt.option):
558
- # mo.ui.dropdown doesn't support disabled; filter to one option as a
559
- # workaround so the user can't change the value. Remove once marimo adds
560
- # disabled support for dropdowns.
586
+ # mo.ui.dropdown doesn't support disabled;
587
+ # (see https://github.com/marimo-team/marimo/issues/9579)
588
+ # So we filter to one option as a workaround
589
+ # so the user can't change the value.
561
590
  override = self._overrides[self._override_key(opt.option)]
562
591
  options = (
563
592
  {override: None if override is None else options[override]}
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