moops 0.1.0__py3-none-any.whl
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/__init__.py +6 -0
- moops/_cli_map.py +28 -0
- moops/_naming.py +27 -0
- moops/_options.py +252 -0
- moops/_parse.py +62 -0
- moops/_run.py +23 -0
- moops/group.py +359 -0
- moops/interface.py +226 -0
- moops/presets.py +32 -0
- moops/testing.py +8 -0
- moops-0.1.0.dist-info/METADATA +108 -0
- moops-0.1.0.dist-info/RECORD +13 -0
- moops-0.1.0.dist-info/WHEEL +4 -0
moops/__init__.py
ADDED
moops/_cli_map.py
ADDED
|
@@ -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()
|
moops/_naming.py
ADDED
|
@@ -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)
|
moops/_options.py
ADDED
|
@@ -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)}"]
|
moops/_parse.py
ADDED
|
@@ -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
|
moops/_run.py
ADDED
|
@@ -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"]
|
moops/group.py
ADDED
|
@@ -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>'
|
moops/interface.py
ADDED
|
@@ -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
|
moops/presets.py
ADDED
|
@@ -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))
|
moops/testing.py
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).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
moops/__init__.py,sha256=z2ySgrZ2w3pfGaha0O2erU91lAGCECyDgv00TZiIF6U,161
|
|
2
|
+
moops/_cli_map.py,sha256=bnY2dsdtELS7oyqrOY_lhdwEIEHSTNMjOshhowaZrIQ,920
|
|
3
|
+
moops/_naming.py,sha256=MUI5T_aMd5juIxNHiPSmivZTzKkbUi6mhmkW2FjYemM,917
|
|
4
|
+
moops/_options.py,sha256=-8V1yWHJNNsAC0jfXcDXIZA2qKraUDARu_lJ5_4Q85M,7845
|
|
5
|
+
moops/_parse.py,sha256=yDVoi-SeZtVK5ovLgsk4NBiccSUtRDLdHy8lvRdKhjk,1854
|
|
6
|
+
moops/_run.py,sha256=3AahW2Z-8A6JkyDaWOKO1Ch3l4TlIJKtAjW9eSHPxgY,791
|
|
7
|
+
moops/group.py,sha256=PLhafp9VZgP4KkTeRcXV9cFGdIXPFmzo9gMGiJdYNdA,12181
|
|
8
|
+
moops/interface.py,sha256=9gdnWypPZqJSH4rvf7ceu6i3vyLuoxFpgHSYMXohj3c,8392
|
|
9
|
+
moops/presets.py,sha256=OqVtNSEK0n3MHQI0U0HrOeQH5zd3nbDa7hLx2uyDGfc,1023
|
|
10
|
+
moops/testing.py,sha256=UPeYL1XdO4XJkKW-ZXc4JbEBOjM3mdJarid2gZcNm_s,225
|
|
11
|
+
moops-0.1.0.dist-info/WHEEL,sha256=1uB4K0BXH0KDzZOLGYqB4NnSgGon9oosxpA-QGkDuEA,80
|
|
12
|
+
moops-0.1.0.dist-info/METADATA,sha256=ba8kBdL9mzqRt5cgVQavt5Qwn8R63dB_VG4Xd4BGjUs,3588
|
|
13
|
+
moops-0.1.0.dist-info/RECORD,,
|