moops 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
moops-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: moops
3
+ Version: 0.1.0
4
+ Summary: Write Marimo notebooks that also work as CLI scripts, with unified UI controls
5
+ Keywords: marimo,notebooks,cli,testing
6
+ Author: Yair Chuchem
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Dist: marimo>=0.23.1
17
+ Requires-Dist: hypothesis
18
+ Requires-Python: >=3.10
19
+ Project-URL: Homepage, https://github.com/yairchu/moops
20
+ Project-URL: Repository, https://github.com/yairchu/moops
21
+ Project-URL: Issues, https://github.com/yairchu/moops/issues
22
+ Description-Content-Type: text/markdown
23
+
24
+ # moops
25
+
26
+ Easily write Marimo notebooks that work as CLI scripts (and more!) with minimal boilerplate.
27
+
28
+ Marimo supports notebooks running as CLI scripts,
29
+ but until now this required maintaining matching input handling implementations.
30
+
31
+ Using `moops`, both implementations are merged into one.
32
+
33
+ ## Installation
34
+
35
+ `uv add` (or `pip install`) `moops`
36
+
37
+ ## Transition guide
38
+
39
+ * Create your argument group: `args = moops.Group()`
40
+ * Replace your `mo.ui` usages with using methods of `args`
41
+ * Add `args.interface` call, preferably as the top cell, and provide the UI elements to it. This makes the notebook works as a script and adds info about it in the notebook.
42
+
43
+ Now your notebook doubles as a CLI script
44
+
45
+ ## Running notebooks from Python
46
+
47
+ Notebooks can also be called from Python with `moops.run`.
48
+ This is useful for testing notebook logic without launching Marimo,
49
+ and for reusing notebook logic from other code.
50
+
51
+ Expose a variable named `result` from the notebook:
52
+
53
+ ```python
54
+ @app.cell
55
+ def _(input_text, mode_dropdown):
56
+ result = mode_dropdown.value(input_text.value)
57
+ return (result,)
58
+ ```
59
+
60
+ Then call the notebook module directly:
61
+
62
+ ```python
63
+ import moops
64
+ from examples import name_casing
65
+
66
+ result = moops.run(
67
+ name_casing,
68
+ text="Hello World",
69
+ style="snake_case",
70
+ )
71
+
72
+ assert result == "hello_world"
73
+ ```
74
+
75
+ Keyword arguments override `moops.Group` inputs by their option names,
76
+ with leading dashes removed and dashes converted to underscores.
77
+ If no overrides are provided, `moops.run` uses the notebook defaults.
78
+
79
+ ## Property-based testing
80
+
81
+ `moops.testing.notebook_interface` 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.
82
+
83
+ ```python
84
+ from examples import name_casing
85
+
86
+ _name_casing_interface = moops.testing.notebook_interface(name_casing)
87
+ _name_casing_defaults = _name_casing_interface.default
88
+
89
+ @hypothesis.given(_name_casing_interface.strategy())
90
+ def test_name_casing_preserves_alphanumeric_count(kwargs):
91
+ result = moops.run(name_casing, **kwargs)
92
+ input_text = kwargs.get("input_text", _name_casing_defaults["input_text"])
93
+ assert sum(c.isalnum() for c in result) == sum(c.isalnum() for c in input_text)
94
+ ```
95
+
96
+ ## Running the examples
97
+
98
+ From the project root:
99
+
100
+ ```sh
101
+ uv run examples/notebook.py
102
+ ```
103
+
104
+ Or `uv run marimo edit` to run as notebooks.
105
+
106
+ ## Feedback welcome
107
+
108
+ This is an early release — issues, ideas, and pull requests are very welcome on [GitHub](https://github.com/yairchu/moops).
moops-0.1.0/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # moops
2
+
3
+ Easily write Marimo notebooks that work as CLI scripts (and more!) with minimal boilerplate.
4
+
5
+ Marimo supports notebooks running as CLI scripts,
6
+ but until now this required maintaining matching input handling implementations.
7
+
8
+ Using `moops`, both implementations are merged into one.
9
+
10
+ ## Installation
11
+
12
+ `uv add` (or `pip install`) `moops`
13
+
14
+ ## Transition guide
15
+
16
+ * Create your argument group: `args = moops.Group()`
17
+ * Replace your `mo.ui` usages with using methods of `args`
18
+ * Add `args.interface` call, preferably as the top cell, and provide the UI elements to it. This makes the notebook works as a script and adds info about it in the notebook.
19
+
20
+ Now your notebook doubles as a CLI script
21
+
22
+ ## Running notebooks from Python
23
+
24
+ Notebooks can also be called from Python with `moops.run`.
25
+ This is useful for testing notebook logic without launching Marimo,
26
+ and for reusing notebook logic from other code.
27
+
28
+ Expose a variable named `result` from the notebook:
29
+
30
+ ```python
31
+ @app.cell
32
+ def _(input_text, mode_dropdown):
33
+ result = mode_dropdown.value(input_text.value)
34
+ return (result,)
35
+ ```
36
+
37
+ Then call the notebook module directly:
38
+
39
+ ```python
40
+ import moops
41
+ from examples import name_casing
42
+
43
+ result = moops.run(
44
+ name_casing,
45
+ text="Hello World",
46
+ style="snake_case",
47
+ )
48
+
49
+ assert result == "hello_world"
50
+ ```
51
+
52
+ Keyword arguments override `moops.Group` inputs by their option names,
53
+ with leading dashes removed and dashes converted to underscores.
54
+ If no overrides are provided, `moops.run` uses the notebook defaults.
55
+
56
+ ## Property-based testing
57
+
58
+ `moops.testing.notebook_interface` 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.
59
+
60
+ ```python
61
+ from examples import name_casing
62
+
63
+ _name_casing_interface = moops.testing.notebook_interface(name_casing)
64
+ _name_casing_defaults = _name_casing_interface.default
65
+
66
+ @hypothesis.given(_name_casing_interface.strategy())
67
+ def test_name_casing_preserves_alphanumeric_count(kwargs):
68
+ result = moops.run(name_casing, **kwargs)
69
+ input_text = kwargs.get("input_text", _name_casing_defaults["input_text"])
70
+ assert sum(c.isalnum() for c in result) == sum(c.isalnum() for c in input_text)
71
+ ```
72
+
73
+ ## Running the examples
74
+
75
+ From the project root:
76
+
77
+ ```sh
78
+ uv run examples/notebook.py
79
+ ```
80
+
81
+ Or `uv run marimo edit` to run as notebooks.
82
+
83
+ ## Feedback welcome
84
+
85
+ This is an early release — issues, ideas, and pull requests are very welcome on [GitHub](https://github.com/yairchu/moops).
@@ -0,0 +1,59 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.6"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "moops"
7
+ version = "0.1.0"
8
+ description = "Write Marimo notebooks that also work as CLI scripts, with unified UI controls"
9
+ license = "MIT"
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Yair Chuchem" },
14
+ ]
15
+ keywords = ["marimo", "notebooks", "cli", "testing"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ ]
26
+ dependencies = [
27
+ "marimo>=0.23.1",
28
+ "hypothesis",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/yairchu/moops"
33
+ Repository = "https://github.com/yairchu/moops"
34
+ Issues = "https://github.com/yairchu/moops/issues"
35
+
36
+ [tool.uv]
37
+ dev-dependencies = [
38
+ "ruff>=0.1.0",
39
+ "pyright>=1.1.409",
40
+ "pytest>=9.0.3",
41
+ "vulture>=2.16",
42
+ ]
43
+
44
+ [tool.pytest.ini_options]
45
+ pythonpath = ["src", "."]
46
+
47
+ [tool.pyright]
48
+ typeCheckingMode = "strict"
49
+ include = ["src", "tests"]
50
+ extraPaths = ["src", ".", "examples"]
51
+
52
+ [tool.uv.sources]
53
+ moops = { workspace = true }
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "I", "B", "RUF", "C4", "SIM", "N", "Q", "TID", "UP", "PT", "RET"]
57
+
58
+ [tool.ruff.lint.per-file-ignores]
59
+ "examples/*.py" = ["F401", "F841", "B018"]
@@ -0,0 +1,6 @@
1
+ from ._run import run
2
+ from .group import Group
3
+ from .interface import Interface
4
+ from .presets import Presets
5
+
6
+ __all__ = ["Group", "Interface", "Presets", "run"]
@@ -0,0 +1,28 @@
1
+ import typing
2
+ import weakref
3
+
4
+ from . import _options
5
+
6
+
7
+ class CliMap:
8
+ """Maps UI controls to their CLI counterparts."""
9
+
10
+ def __init__(self) -> None:
11
+ self._lookup: dict[int, _options.CliControl] = {}
12
+
13
+ def register(self, control: typing.Any, cli: _options.CliControl) -> typing.Any:
14
+ ctrl_id = id(control)
15
+ self._lookup[ctrl_id] = cli
16
+
17
+ # WeakKeyDictionary would be cleaner but requires hashable keys; marimo
18
+ # controls (e.g. dropdown) define __eq__ without __hash__, so we use
19
+ # finalize() to remove the entry when the control is GC'd instead.
20
+ weakref.finalize(control, self._lookup.pop, ctrl_id, None)
21
+
22
+ return control
23
+
24
+ def get(self, control: typing.Any) -> _options.CliControl | None:
25
+ return self._lookup.get(id(control))
26
+
27
+ def items(self) -> typing.Iterator[tuple[int, _options.CliControl]]:
28
+ yield from self._lookup.items()
@@ -0,0 +1,27 @@
1
+ import dataclasses
2
+
3
+
4
+ @dataclasses.dataclass
5
+ class OptionLabel:
6
+ """Maps between UI labels and CLI option names."""
7
+
8
+ label: str
9
+ option: str
10
+
11
+ @staticmethod
12
+ def make(
13
+ label: str | None, option: str | None, prefix: str | None = None
14
+ ) -> "OptionLabel":
15
+ """Generate OptionLabel from label or option name."""
16
+
17
+ if option is None:
18
+ assert label is not None, "Either label or option must be provided"
19
+ option = f"--{prefix or ''}{label.lower().replace(' ', '-')}"
20
+ else:
21
+ assert option.startswith("-"), f"Option must start with dash: {option}"
22
+ assert prefix is None or option.startswith(f"--{prefix}"), (
23
+ f"Option {option} must start with --{prefix}"
24
+ )
25
+ if label is None:
26
+ label = option.lstrip("-").replace("-", " ")
27
+ return OptionLabel(label=label, option=option)
@@ -0,0 +1,252 @@
1
+ import abc
2
+ import dataclasses
3
+ import math
4
+ import shlex
5
+ import sys
6
+ import typing
7
+
8
+ import marimo as mo
9
+ from hypothesis import strategies as st
10
+
11
+ from . import _parse
12
+
13
+
14
+ @dataclasses.dataclass
15
+ class ParseError:
16
+ message: str
17
+
18
+
19
+ @dataclasses.dataclass
20
+ class ParseResult:
21
+ value: typing.Any
22
+
23
+
24
+ @dataclasses.dataclass
25
+ class CliControl(abc.ABC):
26
+ """CLI interface for a single UI control."""
27
+
28
+ option: str
29
+ help_text: str
30
+
31
+ def options(self) -> set[str]:
32
+ """Value options for this control."""
33
+ return set()
34
+
35
+ def flags(self) -> set[str]:
36
+ """Flags for this control."""
37
+ return set()
38
+
39
+ @abc.abstractmethod
40
+ def parse(self, args: _parse.ParsedArgs) -> ParseResult | ParseError | None:
41
+ """Parse from CLI args. Returns value, ParseError, or None if not provided."""
42
+
43
+ @abc.abstractmethod
44
+ def strategy(self) -> st.SearchStrategy:
45
+ """Hypothesis strategy for generating override values."""
46
+
47
+ @abc.abstractmethod
48
+ def format_usage_parts(self) -> list[str]:
49
+ """Usage tokens for this control, e.g. ['[--flag]'] or ['[--name NAME]']."""
50
+
51
+ @abc.abstractmethod
52
+ def format_help_lines(self) -> list[str]:
53
+ """Help lines for this control and any aux flags."""
54
+
55
+ @abc.abstractmethod
56
+ def format_value(self, value: typing.Any) -> list[str]:
57
+ """Format the command line arguments for a given value."""
58
+
59
+
60
+ @dataclasses.dataclass
61
+ class FlagControl(CliControl):
62
+ default: bool = False
63
+
64
+ def flags(self) -> set[str]:
65
+ return {self.option}
66
+
67
+ def parse(self, args: _parse.ParsedArgs) -> ParseResult | None:
68
+ return ParseResult(not self.default) if self.option in args.options else None
69
+
70
+ def strategy(self) -> st.SearchStrategy:
71
+ return st.booleans()
72
+
73
+ def format_usage_parts(self) -> list[str]:
74
+ return [f"[{self.option}]"]
75
+
76
+ def format_help_lines(self) -> list[str]:
77
+ return [f" {self.option}: {self.help_text}"]
78
+
79
+ def format_value(self, value: typing.Any) -> list[str]:
80
+ return [] if value == self.default else [self.option]
81
+
82
+
83
+ @dataclasses.dataclass
84
+ class ValueControl(CliControl):
85
+ """Base class for controls that take a value, like text or dropdowns."""
86
+
87
+ metavar: str
88
+
89
+ def options(self) -> set[str]:
90
+ return {self.option}
91
+
92
+ def format_usage_parts(self) -> list[str]:
93
+ return [f"[{self.option} {self.metavar}]"]
94
+
95
+
96
+ @dataclasses.dataclass
97
+ class TextControl(ValueControl):
98
+ default: str
99
+
100
+ def parse(self, args: _parse.ParsedArgs) -> ParseResult | None:
101
+ res = args.options.get(self.option)
102
+ return None if res is None else ParseResult(res)
103
+
104
+ def strategy(self) -> st.SearchStrategy:
105
+ return st.text()
106
+
107
+ def format_help_lines(self) -> list[str]:
108
+ line = f" {self.option} {self.metavar}: {self.help_text}"
109
+ if self.default:
110
+ line += f" (default: {self.default})"
111
+ return [line]
112
+
113
+ def format_value(self, value: typing.Any) -> list[str]:
114
+ return [] if value == self.default else [f"{self.option} {shlex.quote(value)}"]
115
+
116
+
117
+ @dataclasses.dataclass
118
+ class TextAreaControl(ValueControl):
119
+ default: str
120
+
121
+ @property
122
+ def _stdin_flag(self) -> str:
123
+ return f"{self.option}-from-stdin"
124
+
125
+ def flags(self) -> set[str]:
126
+ return {self._stdin_flag}
127
+
128
+ def parse(self, args: _parse.ParsedArgs) -> ParseResult | ParseError | None:
129
+ if not mo.running_in_notebook() and self._stdin_flag in args.options:
130
+ if args.options[self._stdin_flag] is not None:
131
+ return None
132
+ if self.option in args.options:
133
+ return ParseError(
134
+ f"Cannot use both {self.option} and {self._stdin_flag}"
135
+ )
136
+ return ParseResult(sys.stdin.read())
137
+ res = args.options.get(self.option)
138
+ return None if res is None else ParseResult(res)
139
+
140
+ def strategy(self) -> st.SearchStrategy:
141
+ return st.text()
142
+
143
+ def format_usage_parts(self) -> list[str]:
144
+ return [f"[{self.option} {self.metavar}]", f"[{self._stdin_flag}]"]
145
+
146
+ def format_help_lines(self) -> list[str]:
147
+ line = f" {self.option} {self.metavar}: {self.help_text}"
148
+ if self.default:
149
+ line += f" (default: {self.default})"
150
+ return [line, f" {self._stdin_flag}: Read {self.option} from stdin"]
151
+
152
+ def format_value(self, value: typing.Any) -> list[str]:
153
+ return [] if value == self.default else [f"{self.option} {shlex.quote(value)}"]
154
+
155
+
156
+ @dataclasses.dataclass
157
+ class NumberControl(ValueControl):
158
+ default: float | int | None
159
+
160
+ def parse(self, args: _parse.ParsedArgs) -> ParseResult | ParseError | None:
161
+ value = args.options.get(self.option)
162
+ if value is None:
163
+ return None
164
+ try:
165
+ num = float(value)
166
+ except ValueError:
167
+ return ParseError(f"Option {self.option} expects a number, got: {value!r}")
168
+ return ParseResult(int(num) if math.isfinite(num) and num == int(num) else num)
169
+
170
+ def strategy(self) -> st.SearchStrategy:
171
+ return st.one_of(
172
+ st.none(),
173
+ st.integers() | st.floats(allow_nan=False, allow_infinity=False),
174
+ )
175
+
176
+ def format_help_lines(self) -> list[str]:
177
+ line = f" {self.option} {self.metavar}: {self.help_text}"
178
+ if self.default is not None:
179
+ line += f" (default: {self.default})"
180
+ return [line]
181
+
182
+ def format_value(self, value: typing.Any) -> list[str]:
183
+ return [] if value == self.default else [f"{self.option} {value}"]
184
+
185
+
186
+ @dataclasses.dataclass
187
+ class DropdownControl(CliControl):
188
+ allowed_values: list[str]
189
+ supports_none: bool
190
+ default: str | None
191
+
192
+ def options(self) -> set[str]:
193
+ return {self.option}
194
+
195
+ @property
196
+ def has_no_flag(self) -> bool:
197
+ return self.supports_none and self.default is not None
198
+
199
+ @property
200
+ def _no_flag(self) -> str | None:
201
+ return f"--no-{self.option.lstrip('-')}" if self.has_no_flag else None
202
+
203
+ def flags(self) -> set[str]:
204
+ no_flag = self._no_flag
205
+ return {no_flag} if no_flag else set()
206
+
207
+ def parse(self, args: _parse.ParsedArgs) -> ParseResult | ParseError | None:
208
+ no_flag = self._no_flag
209
+ if no_flag and no_flag in args.options:
210
+ if self.option in args.options:
211
+ return ParseError(f"Cannot use both {self.option} and {no_flag}")
212
+ return ParseResult(None)
213
+ raw = args.options.get(self.option)
214
+ if raw is None:
215
+ return None
216
+ if raw not in self.allowed_values:
217
+ return ParseError(
218
+ f"Option {self.option} must be one of"
219
+ f" {self.allowed_values!r}, got: {raw!r}"
220
+ )
221
+ return ParseResult(raw)
222
+
223
+ def strategy(self) -> st.SearchStrategy:
224
+ return st.sampled_from(
225
+ [None, *self.allowed_values] if self.supports_none else self.allowed_values
226
+ )
227
+
228
+ def format_usage_parts(self) -> list[str]:
229
+ parts = [f"[{self.option} {self._values_text()}]"]
230
+ if self._no_flag:
231
+ parts.append(f"[{self._no_flag}]")
232
+ return parts
233
+
234
+ def _values_text(self) -> str:
235
+ return "{" + "|".join(self.allowed_values) + "}"
236
+
237
+ def format_help_lines(self) -> list[str]:
238
+ line = f" {self.option} {self._values_text()}: {self.help_text}"
239
+ if self.default is not None:
240
+ line += f" (default: {self.default})"
241
+ lines = [line]
242
+ if self._no_flag:
243
+ lines.append(f" {self._no_flag}: Set {self.option} to none")
244
+ return lines
245
+
246
+ def format_value(self, value: typing.Any) -> list[str]:
247
+ if value == self.default:
248
+ return []
249
+ if value is None:
250
+ assert self._no_flag
251
+ return [self._no_flag]
252
+ return [f"{self.option} {shlex.quote(value)}"]
@@ -0,0 +1,62 @@
1
+ import dataclasses
2
+ import sys
3
+
4
+ import marimo as mo
5
+
6
+ help_flags = ["--help", "-h"]
7
+
8
+
9
+ @dataclasses.dataclass
10
+ class ParsedArgs:
11
+ options: dict[str, str | None]
12
+ unexpected: list[str]
13
+
14
+ @property
15
+ def is_help(self) -> bool:
16
+ return any(x in self.options for x in help_flags)
17
+
18
+ @classmethod
19
+ def from_options(cls, args: list[str]) -> "ParsedArgs":
20
+ """Parse a pre-tokenized list of options (no command name)."""
21
+
22
+ options: dict[str, str | None] = {}
23
+ unexpected: list[str] = []
24
+ prev = None
25
+ for arg in args:
26
+ is_negative_num = len(arg) > 1 and arg[0] == "-" and arg[1].isdigit()
27
+ if arg.startswith("-") and not (prev is not None and is_negative_num):
28
+ if "=" in arg:
29
+ key, value = arg.split("=", 1)
30
+ options[key] = value
31
+ prev = None
32
+ else:
33
+ options[arg] = None
34
+ prev = arg
35
+ elif prev is not None and prev.startswith("-"):
36
+ options[prev] = arg
37
+ prev = None
38
+ else:
39
+ unexpected.append(arg)
40
+ return cls(options=options, unexpected=unexpected)
41
+
42
+
43
+ @dataclasses.dataclass
44
+ class ParseState:
45
+ """Shared mutable state between a Group and all its subgroups."""
46
+
47
+ args: ParsedArgs
48
+ validation_errors: dict[str, str] = dataclasses.field(
49
+ default_factory=dict[str, str]
50
+ )
51
+
52
+
53
+ def split_argv(args: list[str] | None) -> tuple[str, list[str]]:
54
+ """Split argv-shaped input into (command, options)."""
55
+ if args is None:
56
+ args = sys.argv
57
+ if mo.running_in_notebook():
58
+ # When notebooks embed other notebooks,
59
+ # the outer notebook is the last argument in sys.argv
60
+ args = args[-1:]
61
+ cmd, *rest = args
62
+ return cmd, rest
@@ -0,0 +1,23 @@
1
+ import types
2
+ import typing
3
+
4
+ from . import group
5
+
6
+
7
+ def run(module: types.ModuleType, **kwargs: typing.Any) -> typing.Any:
8
+ """Run a notebook as a function, returning its `result` variable.
9
+
10
+ Keyword arguments override control values by option name
11
+ (leading dashes removed and dashes replaced with underscores). For example,
12
+ a text area with option "--input-text" is overridden with input_text="...".
13
+ All controls are overridable, including those not passed to interface.
14
+ """
15
+ args = group.Group.with_overrides(kwargs)
16
+
17
+ _, defs = module.app.run(defs={"args": args})
18
+ if "result" not in defs:
19
+ raise RuntimeError(
20
+ f"moops.run() expected {module.__name__} to expose a variable named "
21
+ "'result'"
22
+ )
23
+ return defs["result"]
@@ -0,0 +1,359 @@
1
+ import html
2
+ import inspect
3
+ import pathlib
4
+ import shlex
5
+ import typing
6
+ import warnings
7
+
8
+ import marimo as mo
9
+
10
+ from . import _cli_map, _naming, _options, _parse, interface
11
+ from .presets import Presets
12
+
13
+
14
+ class Group:
15
+ """Unified CLI argument parser and marimo UI element generator."""
16
+
17
+ def __init__(
18
+ self,
19
+ cli_args: list[str] | None = None,
20
+ presets: Presets | None = None,
21
+ ) -> None:
22
+ """Initialize with command line arguments (defaults to sys.argv)."""
23
+
24
+ self.option: str = ""
25
+ command, rest = _parse.split_argv(cli_args)
26
+ self._command = command
27
+ self._state = _parse.ParseState(args=_parse.ParsedArgs.from_options(rest))
28
+ self._cli_map = _cli_map.CliMap()
29
+ self._overrides: dict[str, typing.Any] = {}
30
+ self._presets = presets
31
+ self._preset_state = self._build_preset_state()
32
+
33
+ @classmethod
34
+ def with_overrides(cls, overrides: dict[str, typing.Any]) -> "Group":
35
+ instance = cls(["run"])
36
+ instance._overrides = overrides
37
+ return instance
38
+
39
+ def subgroup(
40
+ self,
41
+ prefix: str,
42
+ overrides: dict[str, typing.Any] | None = None,
43
+ presets: Presets | None = None,
44
+ ) -> "Group":
45
+ """Create a child Group that prefixes all its option names with '{prefix}-'.
46
+
47
+ Pass a nested dict under the same key to moops.run() to override controls
48
+ in this subgroup: moops.run(notebook, casing={"style": "snake_case"}).
49
+ Explicit overrides take precedence over those passed via moops.run().
50
+
51
+ Pass `presets=` to give the subgroup its own preset selector; otherwise
52
+ it inherits the parent's preset state.
53
+ """
54
+ child = type(self)([prefix])
55
+ child._state = self._state
56
+ child._cli_map = _cli_map.CliMap()
57
+ child._overrides = {**self._overrides.get(prefix, {}), **(overrides or {})}
58
+ child.option = f"{self.option}-{prefix}" if self.option else f"--{prefix}"
59
+ child._presets = presets
60
+ child._preset_state = (
61
+ child._build_preset_state() if presets else self._preset_state
62
+ )
63
+ return child
64
+
65
+ def _build_preset_state(self) -> _parse.ParseState | None:
66
+ if self._presets is None or self._presets.selected_args is None:
67
+ return None
68
+ args = _parse.ParsedArgs.from_options(shlex.split(self._presets.selected_args))
69
+ return _parse.ParseState(args=args)
70
+
71
+ def interface(self, *controls: typing.Any) -> interface.Interface:
72
+ """
73
+ group.interface() serves two purposes:
74
+ * Display help text based on the defined flags and options.
75
+ * Verify the arguments passed to the script.
76
+
77
+ Pass all controls created by this group so that the registry stays in
78
+ sync with what is actually live (handles cell reruns and deletions).
79
+ """
80
+
81
+ iface = interface.Interface(
82
+ controls,
83
+ cli_map=self._cli_map,
84
+ overrides=self._overrides,
85
+ notebook_name=pathlib.Path(inspect.stack()[1].filename).name,
86
+ option_prefix=self.option,
87
+ presets=self._presets,
88
+ command=self._command,
89
+ )
90
+ if self.option or not mo.running_in_notebook():
91
+ missing_options = iface.missing_options()
92
+ if missing_options:
93
+ warnings.warn(
94
+ f"Controls registered with this Group "
95
+ f"but not passed to interface(): {', '.join(missing_options)}",
96
+ stacklevel=2,
97
+ )
98
+ if not self.option and not mo.running_in_notebook():
99
+ iface.validate_or_exit(self._state)
100
+ return iface
101
+
102
+ def md(self, text: str) -> mo.Html | None:
103
+ """Display markdown in notebooks or plain text in CLI."""
104
+
105
+ if mo.running_in_notebook():
106
+ return mo.md(text)
107
+ if self._state.args.is_help:
108
+ return None
109
+ text = text.strip()
110
+ if text.startswith("```\n") and text.endswith("\n```"):
111
+ text = text[4:-4]
112
+ elif text.startswith("`") and text.endswith("`"):
113
+ text = text[1:-1]
114
+ print(f"{text}\n")
115
+ return None
116
+
117
+ def switch(
118
+ self,
119
+ value: bool = False,
120
+ flag: str | None = None,
121
+ *,
122
+ help_text: str,
123
+ label: str | None = None,
124
+ **kwargs: typing.Any,
125
+ ) -> mo.ui.switch:
126
+ """Create a switch UI element that maps to a CLI flag."""
127
+
128
+ opt = self._make_opt(label=label, option=flag, prefix="no-" if value else None)
129
+ cli = _options.FlagControl(
130
+ option=opt.option, help_text=help_text, default=value
131
+ )
132
+ return self._cli_map.register(
133
+ mo.ui.switch(
134
+ value=self._get_value(cli, value),
135
+ label=_label_with_help(opt.label, help_text),
136
+ disabled=self._is_overridden(opt.option),
137
+ **kwargs,
138
+ ),
139
+ cli,
140
+ )
141
+
142
+ def text(
143
+ self,
144
+ value: str = "",
145
+ placeholder: str = "",
146
+ option: str | None = None,
147
+ *,
148
+ help_text: str,
149
+ label: str | None = None,
150
+ **kwargs: typing.Any,
151
+ ) -> mo.ui.text:
152
+ """Create a text input UI element that maps to a CLI option."""
153
+
154
+ opt = self._make_opt(label=label, option=option)
155
+ cli = _options.TextControl(
156
+ option=opt.option,
157
+ metavar=placeholder or opt.label.upper().replace(" ", "_"),
158
+ help_text=help_text,
159
+ default=value,
160
+ )
161
+ return self._cli_map.register(
162
+ mo.ui.text(
163
+ value=self._get_value(cli, value),
164
+ label=_label_with_help(opt.label, help_text),
165
+ disabled=self._is_overridden(opt.option),
166
+ **kwargs,
167
+ ),
168
+ cli,
169
+ )
170
+
171
+ def text_area(
172
+ self,
173
+ value: str = "",
174
+ placeholder: str = "",
175
+ option: str | None = None,
176
+ *,
177
+ help_text: str,
178
+ label: str | None = None,
179
+ **kwargs: typing.Any,
180
+ ) -> mo.ui.text_area:
181
+ """Create a text area UI element that maps to a CLI option."""
182
+
183
+ opt = self._make_opt(label=label, option=option)
184
+ cli = _options.TextAreaControl(
185
+ option=opt.option,
186
+ metavar=placeholder or opt.label.upper().replace(" ", "_"),
187
+ help_text=help_text,
188
+ default=value,
189
+ )
190
+ return self._cli_map.register(
191
+ mo.ui.text_area(
192
+ value=self._get_value(cli, value),
193
+ label=_label_with_help(opt.label, help_text),
194
+ disabled=self._is_overridden(opt.option),
195
+ **kwargs,
196
+ ),
197
+ cli,
198
+ )
199
+
200
+ def number(
201
+ self,
202
+ start: float | None = None,
203
+ value: float | None = None,
204
+ option: str | None = None,
205
+ *,
206
+ help_text: str,
207
+ label: str | None = None,
208
+ **kwargs: typing.Any,
209
+ ) -> mo.ui.number:
210
+ """Create a number input UI element that maps to a CLI option."""
211
+
212
+ opt, cli, value = self._numeric_cli(start, value, option, help_text, label)
213
+ return self._cli_map.register(
214
+ mo.ui.number(
215
+ start=start,
216
+ value=value,
217
+ label=_label_with_help(opt.label, help_text),
218
+ disabled=self._is_overridden(opt.option),
219
+ **kwargs,
220
+ ),
221
+ cli,
222
+ )
223
+
224
+ def slider(
225
+ self,
226
+ start: float | None = None,
227
+ value: float | None = None,
228
+ option: str | None = None,
229
+ *,
230
+ help_text: str,
231
+ label: str | None = None,
232
+ **kwargs: typing.Any,
233
+ ) -> mo.ui.slider:
234
+ """Create a slider UI element that maps to a CLI option."""
235
+
236
+ opt, cli, value = self._numeric_cli(start, value, option, help_text, label)
237
+ return self._cli_map.register(
238
+ mo.ui.slider(
239
+ start=start,
240
+ value=value,
241
+ label=_label_with_help(opt.label, help_text),
242
+ disabled=self._is_overridden(opt.option),
243
+ **kwargs,
244
+ ),
245
+ cli,
246
+ )
247
+
248
+ def _numeric_cli(
249
+ self,
250
+ start: float | None,
251
+ value: float | None,
252
+ option: str | None,
253
+ help_text: str,
254
+ label: str | None,
255
+ ) -> tuple[_naming.OptionLabel, _options.NumberControl, float | None]:
256
+ if value is None:
257
+ value = start
258
+ opt = self._make_opt(label=label, option=option)
259
+ cli = _options.NumberControl(
260
+ option=opt.option,
261
+ metavar=opt.label.upper().replace(" ", "_"),
262
+ help_text=help_text,
263
+ default=value,
264
+ )
265
+ return opt, cli, self._get_value(cli, value)
266
+
267
+ def dropdown(
268
+ self,
269
+ options: list[str] | dict[str, typing.Any],
270
+ value: str | None = None,
271
+ option: str | None = None,
272
+ *,
273
+ help_text: str,
274
+ label: str | None = None,
275
+ allow_select_none: bool = True,
276
+ **kwargs: typing.Any,
277
+ ) -> mo.ui.dropdown:
278
+ """Create a dropdown UI element that maps to a CLI option."""
279
+
280
+ assert len(options) > 0, "Dropdown options cannot be empty"
281
+ opt = self._make_opt(label=label, option=option)
282
+ keys = list(options)
283
+ if value is None and not allow_select_none:
284
+ value, *_ = keys
285
+ cli = _options.DropdownControl(
286
+ option=opt.option,
287
+ allowed_values=keys,
288
+ supports_none=allow_select_none,
289
+ default=value,
290
+ help_text=help_text,
291
+ )
292
+ if self._is_overridden(opt.option):
293
+ # mo.ui.dropdown doesn't support disabled; filter to one option as a
294
+ # workaround so the user can't change the value. Remove once marimo adds
295
+ # disabled support for dropdowns.
296
+ override = self._overrides[self._override_key(opt.option)]
297
+ options = (
298
+ {override: None if override is None else options[override]}
299
+ if isinstance(options, dict)
300
+ else [override]
301
+ )
302
+ return self._cli_map.register(
303
+ mo.ui.dropdown(
304
+ options=options,
305
+ value=self._get_value(cli, value),
306
+ label=_label_with_help(opt.label, help_text),
307
+ allow_select_none=allow_select_none,
308
+ **kwargs,
309
+ ),
310
+ cli,
311
+ )
312
+
313
+ def _override_key(self, option: str) -> str:
314
+ option = option[len(self.option) :].lstrip("-")
315
+ if option.startswith("no-"):
316
+ option = option[3:]
317
+ return option.replace("-", "_")
318
+
319
+ def _get_value(
320
+ self,
321
+ control: _options.CliControl,
322
+ default: typing.Any,
323
+ ) -> typing.Any:
324
+ key = self._override_key(control.option)
325
+ if key in self._overrides:
326
+ return self._overrides[key]
327
+ if self._preset_state is not None:
328
+ match control.parse(self._preset_state.args):
329
+ case _options.ParseResult(value=v):
330
+ return v
331
+ case _:
332
+ pass
333
+ val = default
334
+ match control.parse(self._state.args):
335
+ case _options.ParseError(message=msg):
336
+ self._state.validation_errors[control.option] = msg
337
+ case _options.ParseResult(value=v):
338
+ val = v
339
+ case None:
340
+ pass
341
+ return val
342
+
343
+ def _is_overridden(self, option: str) -> bool:
344
+ return self._override_key(option) in self._overrides
345
+
346
+ def _make_opt(
347
+ self, label: str | None, option: str | None, prefix: str | None = None
348
+ ) -> _naming.OptionLabel:
349
+ opt = _naming.OptionLabel.make(label=label, option=option, prefix=prefix)
350
+ if self.option:
351
+ opt = _naming.OptionLabel(
352
+ label=opt.label,
353
+ option=f"{self.option}-{opt.option.lstrip('-')}",
354
+ )
355
+ return opt
356
+
357
+
358
+ def _label_with_help(label: str, help_text: str) -> str:
359
+ return f'<span title="{html.escape(help_text, quote=True)}">{label}</span>'
@@ -0,0 +1,226 @@
1
+ import dataclasses
2
+ import sys
3
+ import typing
4
+
5
+ import marimo as mo
6
+ from hypothesis import strategies as st
7
+
8
+ from . import _cli_map, _options, _parse
9
+ from .presets import Presets
10
+
11
+
12
+ @dataclasses.dataclass
13
+ class Interface:
14
+ """Controls registered by a subgroup's interface, for passing to the parent."""
15
+
16
+ controls: tuple[typing.Any]
17
+ cli_map: _cli_map.CliMap = dataclasses.field(default_factory=_cli_map.CliMap)
18
+ overrides: dict[str, typing.Any] = dataclasses.field(default_factory=lambda: {})
19
+ notebook_name: str = ""
20
+ option_prefix: str = ""
21
+ presets: Presets | None = None
22
+ command: str = ""
23
+
24
+ def __post_init__(self) -> None:
25
+ seen_ids: set[int] = set()
26
+ for ctrl in self.controls:
27
+ if not isinstance(ctrl, Interface) and id(ctrl) in seen_ids:
28
+ raise ValueError("Duplicate control passed to interface")
29
+ seen_ids.add(id(ctrl))
30
+
31
+ def validate(self, state: _parse.ParseState) -> typing.Iterator[str]:
32
+ flags: set[str] = set()
33
+ value_options: set[str] = set()
34
+ for cli in self._all_cli_controls():
35
+ flags.update(cli.flags())
36
+ value_options.update(cli.options())
37
+ rendered = flags | value_options
38
+ yield from (v for k, v in state.validation_errors.items() if k in rendered)
39
+ unexp_text = "Unexpected argument: "
40
+ for x in state.args.unexpected:
41
+ yield f"{unexp_text}{x}"
42
+ for k, v in state.args.options.items():
43
+ if k in flags:
44
+ if v is not None:
45
+ yield f"{k} does not take a value, but was given: {v}"
46
+ elif k in value_options:
47
+ if v is None:
48
+ yield f"Option {k} requires a value"
49
+ elif k not in _parse.help_flags:
50
+ yield f"{unexp_text}{k}"
51
+
52
+ def help(self) -> str:
53
+ usage_parts = [
54
+ p for cli in self._all_cli_controls() for p in cli.format_usage_parts()
55
+ ]
56
+ usage_parts.append("[-h/--help]")
57
+ name = self.command.rsplit("/", 1)[-1]
58
+ segments = [f"Usage: {name} {' '.join(usage_parts)}"]
59
+ help_lines = [
60
+ line for cli in self._all_cli_controls() for line in cli.format_help_lines()
61
+ ]
62
+ if help_lines:
63
+ segments.append("\n".join(help_lines))
64
+ return "\n\n".join(segments)
65
+
66
+ @property
67
+ def default(self) -> dict[str, typing.Any]:
68
+ result: dict[str, typing.Any] = {}
69
+ for ctrl in self.controls:
70
+ if isinstance(ctrl, Interface):
71
+ prefix = ctrl.option_prefix.lstrip("-")
72
+ result[prefix] = ctrl.default
73
+ else:
74
+ cli = self.cli_map.get(ctrl)
75
+ if (
76
+ cli is not None
77
+ and not self._is_overridden(cli)
78
+ and hasattr(cli, "default")
79
+ ):
80
+ result[self._key(cli)] = cli.default # type: ignore
81
+ return result
82
+
83
+ def strategy(self) -> st.SearchStrategy[dict[str, typing.Any]]:
84
+ strategies: dict[str, st.SearchStrategy[typing.Any]] = {}
85
+ for ctrl in self.controls:
86
+ if isinstance(ctrl, Interface):
87
+ prefix = ctrl.option_prefix.lstrip("-")
88
+ strategies[prefix] = ctrl.strategy()
89
+ else:
90
+ cli = self.cli_map.get(ctrl)
91
+ if cli is not None and not self._is_overridden(cli):
92
+ strategies[self._key(cli)] = cli.strategy()
93
+ return st.fixed_dictionaries(strategies).map(
94
+ lambda d: {k: v for k, v in d.items() if v is not None}
95
+ )
96
+
97
+ def _all_cli_controls(self) -> typing.Iterator[_options.CliControl]:
98
+ for ctrl in self.controls:
99
+ if isinstance(ctrl, Interface):
100
+ yield from ctrl._all_cli_controls()
101
+ else:
102
+ cli = self.cli_map.get(ctrl)
103
+ if cli is not None and not self._is_overridden(cli):
104
+ yield cli
105
+
106
+ def _key(self, cli: _options.CliControl) -> str:
107
+ option = cli.option[len(self.option_prefix) :].lstrip("-")
108
+ if option.startswith("no-"):
109
+ option = option[3:]
110
+ return option.replace("-", "_")
111
+
112
+ def _is_overridden(self, cli: _options.CliControl) -> bool:
113
+ return self._key(cli) in self.overrides
114
+
115
+ def cur_values(self) -> dict[str, typing.Any]:
116
+ result: dict[str, typing.Any] = {}
117
+ for ctrl in self.controls:
118
+ if isinstance(ctrl, Interface):
119
+ result.update(ctrl.cur_values())
120
+ else:
121
+ cli = self.cli_map.get(ctrl)
122
+ if cli is not None and not self._is_overridden(cli):
123
+ result[cli.option] = _ctrl_value(ctrl)
124
+ return result
125
+
126
+ def _current_args(self) -> str:
127
+ values = self.cur_values()
128
+ return " ".join(
129
+ arg
130
+ for cli in self._all_cli_controls()
131
+ if cli.option in values
132
+ for arg in cli.format_value(values[cli.option])
133
+ )
134
+
135
+ def format_current_command(self) -> str:
136
+ args = self._current_args()
137
+ name = self.command.rsplit("/", 1)[-1]
138
+ return f"{name} {args}" if args else name
139
+
140
+ def missing_options(self) -> list[str]:
141
+ interface_ids = {id(ctrl) for ctrl in self.controls}
142
+ return [
143
+ cli.option
144
+ for ctrl_id, cli in self.cli_map.items()
145
+ if ctrl_id not in interface_ids
146
+ ]
147
+
148
+ def validate_or_exit(self, state: _parse.ParseState) -> None:
149
+ issues = list(self.validate(state))
150
+ if issues:
151
+ print("Argument errors:\n" + "\n".join(f"- {x}" for x in issues))
152
+ print()
153
+ if state.args.is_help or issues:
154
+ print(self.help())
155
+ sys.exit(1 if issues else 0)
156
+
157
+ def _mime_(self) -> tuple[str, str]:
158
+ if self.option_prefix:
159
+ return self._subgroup_summary()._mime_() # type: ignore
160
+ return self._root_panel()._mime_() # type: ignore
161
+
162
+ def _subgroup_summary(self) -> mo.Html:
163
+ if not self.notebook_name:
164
+ return mo.md("Cli bundle with no notebook name")
165
+ has_exposed = any(
166
+ not ctrl._component_args.get("disabled", False)
167
+ for ctrl in self._flatten()
168
+ if hasattr(ctrl, "_component_args")
169
+ )
170
+ prefix_note = (
171
+ f" (configured by the `{self.option_prefix}` options)"
172
+ if has_exposed
173
+ else ""
174
+ )
175
+ return mo.md(f"An embedded instance of `{self.notebook_name}`{prefix_note}.")
176
+
177
+ def _root_panel(self) -> mo.Html:
178
+ info = mo.md(
179
+ f"This notebook also works as a script:\n```\n{self.help()}\n```\n\n"
180
+ "To run the script with the current values in the notebook use:\n"
181
+ f"```\n{self.format_current_command()}\n```"
182
+ )
183
+ items: list[typing.Any] = [info]
184
+ if self.presets is not None:
185
+ current_args = self._current_args()
186
+ modified = current_args != (self.presets.selected_args or "")
187
+ name_input = mo.ui.text(
188
+ placeholder="preset name",
189
+ disabled=not modified,
190
+ )
191
+ save_btn = mo.ui.button(
192
+ label="Save preset",
193
+ on_click=lambda _: self.presets.save( # type: ignore
194
+ name_input.value, current_args
195
+ ),
196
+ disabled=not modified,
197
+ )
198
+ items.append(
199
+ mo.hstack(
200
+ [self.presets, name_input, save_btn],
201
+ justify="start",
202
+ )
203
+ )
204
+ missing_options = self.missing_options()
205
+ if missing_options:
206
+ items.append(
207
+ mo.callout(
208
+ mo.md(
209
+ "Missing options: "
210
+ f"{', '.join(f'`{opt}`' for opt in missing_options)}"
211
+ ),
212
+ "warn",
213
+ )
214
+ )
215
+ return mo.vstack(items)
216
+
217
+ def _flatten(self) -> typing.Iterator[typing.Any]:
218
+ for ctrl in self.controls:
219
+ if isinstance(ctrl, Interface):
220
+ yield from ctrl._flatten()
221
+ else:
222
+ yield ctrl
223
+
224
+
225
+ def _ctrl_value(ctrl: typing.Any) -> typing.Any:
226
+ return ctrl._selected_key if hasattr(ctrl, "_selected_key") else ctrl.value
@@ -0,0 +1,32 @@
1
+ import json
2
+ import pathlib
3
+
4
+ import marimo as mo
5
+
6
+
7
+ class Presets(mo.ui.dropdown):
8
+ """Preset selector backed by a JSON file. Subclass of mo.ui.dropdown so it
9
+ drives marimo reactivity: cells that reference a Presets re-run when the
10
+ selection changes."""
11
+
12
+ def __init__(self, filename: str | pathlib.Path) -> None:
13
+ self._filename = pathlib.Path(filename)
14
+ self._data: dict[str, str] = {}
15
+ if self._filename.exists():
16
+ self._data = json.loads(self._filename.read_text()).get("presets", {})
17
+ super().__init__(
18
+ options=list(self._data),
19
+ value=None,
20
+ label="Preset",
21
+ allow_select_none=True,
22
+ )
23
+
24
+ @property
25
+ def selected_args(self) -> str | None:
26
+ return self._data.get(self.value) if self.value else None
27
+
28
+ def save(self, name: str, args: str) -> None:
29
+ if not name:
30
+ return
31
+ self._data[name] = args
32
+ self._filename.write_text(json.dumps({"presets": self._data}, indent=2))
@@ -0,0 +1,8 @@
1
+ import types
2
+
3
+ from . import group, interface
4
+
5
+
6
+ def notebook_interface(module: types.ModuleType) -> interface.Interface:
7
+ _, defs = module.app.run(defs={"args": group.Group.with_overrides({})})
8
+ return defs["interface"]