moops 0.13.0__tar.gz → 0.13.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.13.0 → moops-0.13.2}/PKG-INFO +1 -1
  2. {moops-0.13.0 → moops-0.13.2}/pyproject.toml +1 -1
  3. {moops-0.13.0 → moops-0.13.2}/src/moops/_control_mirroring.py +1 -1
  4. {moops-0.13.0 → moops-0.13.2}/src/moops/_options.py +4 -0
  5. {moops-0.13.0 → moops-0.13.2}/src/moops/_variant.py +6 -0
  6. {moops-0.13.0 → moops-0.13.2}/src/moops/group.py +1 -0
  7. {moops-0.13.0 → moops-0.13.2}/src/moops/interface.py +49 -24
  8. {moops-0.13.0 → moops-0.13.2}/README.md +0 -0
  9. {moops-0.13.0 → moops-0.13.2}/src/moops/__init__.py +0 -0
  10. {moops-0.13.0 → moops-0.13.2}/src/moops/_choice_options.py +0 -0
  11. {moops-0.13.0 → moops-0.13.2}/src/moops/_custom_element.py +0 -0
  12. {moops-0.13.0 → moops-0.13.2}/src/moops/_embed.py +0 -0
  13. {moops-0.13.0 → moops-0.13.2}/src/moops/_input_map.py +0 -0
  14. {moops-0.13.0 → moops-0.13.2}/src/moops/_interface_utils.py +0 -0
  15. {moops-0.13.0 → moops-0.13.2}/src/moops/_list_controls.py +0 -0
  16. {moops-0.13.0 → moops-0.13.2}/src/moops/_list_options.py +0 -0
  17. {moops-0.13.0 → moops-0.13.2}/src/moops/_marimo_controls.py +0 -0
  18. {moops-0.13.0 → moops-0.13.2}/src/moops/_markdown.py +0 -0
  19. {moops-0.13.0 → moops-0.13.2}/src/moops/_naming.py +0 -0
  20. {moops-0.13.0 → moops-0.13.2}/src/moops/_parse.py +0 -0
  21. {moops-0.13.0 → moops-0.13.2}/src/moops/_preset_state.py +0 -0
  22. {moops-0.13.0 → moops-0.13.2}/src/moops/_presets_ui.py +0 -0
  23. {moops-0.13.0 → moops-0.13.2}/src/moops/_query_params.py +0 -0
  24. {moops-0.13.0 → moops-0.13.2}/src/moops/_run.py +0 -0
  25. {moops-0.13.0 → moops-0.13.2}/src/moops/_run_button.py +0 -0
  26. {moops-0.13.0 → moops-0.13.2}/src/moops/_subgroup_registry.py +0 -0
  27. {moops-0.13.0 → moops-0.13.2}/src/moops/_terminal_graphics.py +0 -0
  28. {moops-0.13.0 → moops-0.13.2}/src/moops/_text_wrap.py +0 -0
  29. {moops-0.13.0 → moops-0.13.2}/src/moops/_ui_workarounds.py +0 -0
  30. {moops-0.13.0 → moops-0.13.2}/src/moops/_value_resolution.py +0 -0
  31. {moops-0.13.0 → moops-0.13.2}/src/moops/presets.py +0 -0
  32. {moops-0.13.0 → moops-0.13.2}/src/moops/workarounds.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moops
3
- Version: 0.13.0
3
+ Version: 0.13.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.13.0"
7
+ version = "0.13.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"
@@ -188,7 +188,7 @@ def _create_control(
188
188
  ) -> typing.Any:
189
189
  """Create a marimo element from an existing InputControl."""
190
190
  display_option = _interface_utils.unprefixed_option(iface, input_control.option)
191
- opt = group._make_opt(label=None, option=display_option)
191
+ opt = group._make_opt(label=input_control.label, option=display_option)
192
192
  cloned = input_control.with_option(opt.option)
193
193
  return group._register_control(opt, cloned, cloned.help_text, None)
194
194
 
@@ -52,6 +52,10 @@ class InputControl(abc.ABC):
52
52
  option: str
53
53
  help_text: str
54
54
  default: typing.Any
55
+ # Resolved display label, stashed at registration so controls_from can
56
+ # mirror it. None until registered; the mirror falls back to the option
57
+ # name. Travels through with_option's dataclasses.replace.
58
+ label: str | None = dataclasses.field(default=None, kw_only=True)
55
59
  extra_kwargs: dict[str, typing.Any] = dataclasses.field(
56
60
  default_factory=dict[str, typing.Any], kw_only=True
57
61
  )
@@ -26,6 +26,12 @@ class VariantContext:
26
26
  def selected_key(selector: typing.Any) -> typing.Any:
27
27
  if hasattr(selector, "_selected_key"):
28
28
  return selector._selected_key
29
+ # A switch/checkbox used as a variant selector has no `_selected_key`. In
30
+ # the controls_from mirroring path the selector is a freshly-cloned widget,
31
+ # so reading `.value` trips marimo's "value accessed in its creating cell"
32
+ # guard; the raw cached `_value` holds the same value without the guard.
33
+ if hasattr(selector, "_value"):
34
+ return selector._value
29
35
  return selector.value
30
36
 
31
37
 
@@ -972,6 +972,7 @@ class Group:
972
972
  help_text: str,
973
973
  on_change: typing.Callable[[typing.Any], None] | None,
974
974
  ) -> typing.Any:
975
+ input_control.label = opt.label
975
976
  reset_state = self._value_resolver.query_on_change(input_control, on_change)
976
977
  control = input_control.make_element(
977
978
  self._value_resolver.get_value(input_control, input_control.default),
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import dataclasses
4
4
  import html
5
+ import os
5
6
  import pathlib
6
7
  import shlex
7
8
  import sys
@@ -179,6 +180,8 @@ class Interface:
179
180
  tokens = shlex.split(text)
180
181
  except ValueError as exc:
181
182
  return (f"Could not parse arguments: {exc}",)
183
+ if tokens[:2] == ["uv", "run"]:
184
+ tokens = tokens[2:]
182
185
  commands = {self.command, pathlib.PurePath(self.command).name}
183
186
  if tokens and tokens[0] in commands:
184
187
  tokens = tokens[1:]
@@ -443,9 +446,7 @@ class Interface:
443
446
  # Non-empty: the parent injected defs beyond args, so no CLI
444
447
  # command can reproduce this setup.
445
448
  return link
446
- command = _text_wrap.wrap_command(
447
- self.notebook_file, self._standalone_arg_groups()
448
- )
449
+ command = _wrap_command(self.notebook_file, self._standalone_arg_groups())
449
450
  return mo.vstack(
450
451
  [link, mo.md(f"To run as a standalone script:\n```\n{command}\n```")]
451
452
  )
@@ -455,48 +456,57 @@ class Interface:
455
456
  query = urllib.parse.urlencode({"file": self.notebook_file, **values})
456
457
  return f"/?{query}"
457
458
 
458
- def _standalone_query_values(self) -> dict[str, str]:
459
- values: dict[str, str] = {}
460
- for ctrl in self.controls:
461
- if isinstance(ctrl, Interface):
462
- values.update(ctrl._standalone_query_values())
463
- elif (sub_iface := attached_interface(ctrl)) is not None:
464
- values.update(self._controls_from_query_values(sub_iface))
465
- else:
466
- self._add_query_value(values, ctrl, self.input_map)
467
- return values
468
-
469
- def _controls_from_query_values(self, sub_iface: Interface) -> dict[str, str]:
470
- """Collect standalone query values for a controls_from mirror.
459
+ def _standalone_query_values(self, prefix: str = "") -> dict[str, str]:
460
+ """Query params reproducing this notebook's state when run standalone.
471
461
 
472
- Uses this interface's key scheme (our option_prefix) so the resulting
473
- URL params match the parent notebook's parameter namespace.
462
+ ``prefix`` is the dotted query-param path of this interface relative to
463
+ the standalone root (empty at the root). A real subgroup keeps its
464
+ segment so its controls round-trip under the same dotted key the
465
+ subgroup reads back (e.g. ``sub.mode``); the embedded-summary link calls
466
+ this on the subgroup itself, so there ``prefix`` is empty and its
467
+ controls go flat, matching the embedded notebook running as root.
474
468
  """
475
469
  values: dict[str, str] = {}
476
- for ctrl in sub_iface.controls:
477
- if (nested := attached_interface(ctrl)) is not None:
478
- values.update(self._controls_from_query_values(nested))
470
+ for ctrl in self.controls:
471
+ if (sub_iface := attached_interface(ctrl)) is not None:
472
+ segment = sub_iface._query_segment_below(self.query_params.prefix)
473
+ child = (
474
+ f"{prefix}.{segment}" if prefix and segment else segment or prefix
475
+ )
476
+ values.update(sub_iface._standalone_query_values(child))
479
477
  else:
480
- self._add_query_value(values, ctrl, sub_iface.input_map)
478
+ self._add_query_value(values, ctrl, self.input_map, prefix)
481
479
  return values
482
480
 
481
+ def _query_segment_below(self, parent_prefix: str) -> str:
482
+ """This interface's dotted query path relative to ``parent_prefix``."""
483
+ prefix = self.query_params.prefix
484
+ if parent_prefix and prefix.startswith(f"{parent_prefix}."):
485
+ return prefix[len(parent_prefix) + 1 :]
486
+ if parent_prefix and prefix == parent_prefix:
487
+ return ""
488
+ return prefix
489
+
483
490
  def _add_query_value(
484
491
  self,
485
492
  values: dict[str, str],
486
493
  ctrl: typing.Any,
487
494
  input_map: _input_map.InputMap,
495
+ prefix: str = "",
488
496
  ) -> None:
489
497
  input_control = input_map.get(ctrl)
490
498
  if input_control is None:
491
499
  return
492
500
  value = input_control.format_query_value(_marimo_controls.ctrl_value(ctrl))
493
501
  if value is not None:
494
- values[_query_params.escape_url_key(self._key(input_control))] = value
502
+ leaf = self._key(input_control)
503
+ full = f"{prefix}.{leaf}" if prefix else leaf
504
+ values[_query_params.escape_url_key(full)] = value
495
505
 
496
506
  def _root_panel(self) -> mo.Html:
497
507
  args = self._current_args()
498
508
  body_items: list[typing.Any] = [mo.md("This notebook also works as a script:")]
499
- command = _text_wrap.wrap_command(self.command, self._arg_groups())
509
+ command = _wrap_command(self.command, self._arg_groups())
500
510
  missing_options = self.missing_options()
501
511
  kind = "warn" if missing_options else "info"
502
512
  if self._presets_ui is not None:
@@ -621,6 +631,21 @@ def attached_interface(ctrl: typing.Any) -> Interface | None:
621
631
  return iface
622
632
 
623
633
 
634
+ def _wrap_command(command: str, groups: list[str]) -> str:
635
+ if _should_use_uv_run(command):
636
+ return _text_wrap.wrap_command("uv", [f"run {shlex.quote(command)}", *groups])
637
+ return _text_wrap.wrap_command(command, groups)
638
+
639
+
640
+ def _should_use_uv_run(command: str) -> bool:
641
+ path = pathlib.Path(command)
642
+ return (
643
+ "UV_RUN_RECURSION_DEPTH" in os.environ
644
+ and path.is_file()
645
+ and not os.access(path, os.X_OK)
646
+ )
647
+
648
+
624
649
  def _reset_value(
625
650
  input_control: _options.InputControl,
626
651
  args: _parse.ParsedArgs,
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