moops 0.2.0__tar.gz → 0.3.1__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.2.0 → moops-0.3.1}/PKG-INFO +11 -1
- {moops-0.2.0 → moops-0.3.1}/README.md +10 -0
- {moops-0.2.0 → moops-0.3.1}/pyproject.toml +1 -1
- {moops-0.2.0 → moops-0.3.1}/src/moops/_input_map.py +7 -0
- moops-0.3.1/src/moops/_markdown.py +47 -0
- {moops-0.2.0 → moops-0.3.1}/src/moops/_options.py +110 -13
- {moops-0.2.0 → moops-0.3.1}/src/moops/_parse.py +20 -7
- moops-0.3.1/src/moops/embed.py +68 -0
- {moops-0.2.0 → moops-0.3.1}/src/moops/group.py +40 -21
- {moops-0.2.0 → moops-0.3.1}/src/moops/interface.py +29 -14
- {moops-0.2.0 → moops-0.3.1}/src/moops/presets.py +35 -2
- {moops-0.2.0 → moops-0.3.1}/src/moops/__init__.py +0 -0
- {moops-0.2.0 → moops-0.3.1}/src/moops/_naming.py +0 -0
- {moops-0.2.0 → moops-0.3.1}/src/moops/_query_params.py +0 -0
- {moops-0.2.0 → moops-0.3.1}/src/moops/_run.py +0 -0
- {moops-0.2.0 → moops-0.3.1}/src/moops/testing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: moops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -100,6 +100,16 @@ those controls from the URL. Query keys use the same names as `moops.run`
|
|
|
100
100
|
keyword arguments. For subgroups, use dot-separated names such as
|
|
101
101
|
`?casing.style=camel_case`.
|
|
102
102
|
|
|
103
|
+
## Presets
|
|
104
|
+
|
|
105
|
+
Presets save and restore named groups of control values from a JSON file stored
|
|
106
|
+
next to the calling notebook as `<notebook>_presets.json`.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
get_preset, set_preset = mo.state(None)
|
|
110
|
+
args = moops.Group(presets=moops.Presets(get_preset, set_preset))
|
|
111
|
+
```
|
|
112
|
+
|
|
103
113
|
## Custom notebook controls
|
|
104
114
|
|
|
105
115
|
Use `args.custom()` when the notebook needs an interactive control that moops
|
|
@@ -77,6 +77,16 @@ those controls from the URL. Query keys use the same names as `moops.run`
|
|
|
77
77
|
keyword arguments. For subgroups, use dot-separated names such as
|
|
78
78
|
`?casing.style=camel_case`.
|
|
79
79
|
|
|
80
|
+
## Presets
|
|
81
|
+
|
|
82
|
+
Presets save and restore named groups of control values from a JSON file stored
|
|
83
|
+
next to the calling notebook as `<notebook>_presets.json`.
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
get_preset, set_preset = mo.state(None)
|
|
87
|
+
args = moops.Group(presets=moops.Presets(get_preset, set_preset))
|
|
88
|
+
```
|
|
89
|
+
|
|
80
90
|
## Custom notebook controls
|
|
81
91
|
|
|
82
92
|
Use `args.custom()` when the notebook needs an interactive control that moops
|
|
@@ -11,8 +11,15 @@ class InputMap:
|
|
|
11
11
|
self._registered: weakref.WeakValueDictionary[str, _options.InputControl] = (
|
|
12
12
|
weakref.WeakValueDictionary()
|
|
13
13
|
)
|
|
14
|
+
self._ui_id_options: dict[typing.Any, str] = {}
|
|
14
15
|
|
|
15
16
|
def register(self, control: typing.Any, cli: _options.InputControl) -> typing.Any:
|
|
17
|
+
ui_id = getattr(control, "_id", None)
|
|
18
|
+
if ui_id is not None:
|
|
19
|
+
old_option = self._ui_id_options.get(ui_id)
|
|
20
|
+
if old_option is not None and old_option != cli.option:
|
|
21
|
+
self._registered.pop(old_option, None)
|
|
22
|
+
self._ui_id_options[ui_id] = cli.option
|
|
16
23
|
self._registered[cli.option] = cli
|
|
17
24
|
control._moops_input = cli
|
|
18
25
|
return control
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
def demote_markdown_headings(text: str, levels: int) -> str:
|
|
2
|
+
if levels <= 0:
|
|
3
|
+
return text
|
|
4
|
+
|
|
5
|
+
fence: tuple[str, int] | None = None
|
|
6
|
+
lines: list[str] = []
|
|
7
|
+
for line in text.splitlines(keepends=True):
|
|
8
|
+
content = line.rstrip("\r\n")
|
|
9
|
+
newline = line[len(content) :]
|
|
10
|
+
fence = _update_markdown_fence(content, fence)
|
|
11
|
+
lines.append(
|
|
12
|
+
content if fence is not None else _demote_markdown_heading(content, levels)
|
|
13
|
+
)
|
|
14
|
+
lines[-1] += newline
|
|
15
|
+
return "".join(lines)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _update_markdown_fence(
|
|
19
|
+
line: str, fence: tuple[str, int] | None
|
|
20
|
+
) -> tuple[str, int] | None:
|
|
21
|
+
stripped = line.lstrip(" ")
|
|
22
|
+
if len(line) - len(stripped) > 3 or not stripped:
|
|
23
|
+
return fence
|
|
24
|
+
marker = stripped[0]
|
|
25
|
+
if marker not in "`~":
|
|
26
|
+
return fence
|
|
27
|
+
count = len(stripped) - len(stripped.lstrip(marker))
|
|
28
|
+
if count < 3:
|
|
29
|
+
return fence
|
|
30
|
+
if fence is None:
|
|
31
|
+
return (marker, count)
|
|
32
|
+
if marker == fence[0] and count >= fence[1] and not stripped[count:].strip():
|
|
33
|
+
return None
|
|
34
|
+
return fence
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _demote_markdown_heading(line: str, levels: int) -> str:
|
|
38
|
+
stripped = line.lstrip(" ")
|
|
39
|
+
indent = len(line) - len(stripped)
|
|
40
|
+
if indent > 3:
|
|
41
|
+
return line
|
|
42
|
+
count = len(stripped) - len(stripped.lstrip("#"))
|
|
43
|
+
if not 1 <= count <= 6:
|
|
44
|
+
return line
|
|
45
|
+
if len(stripped) > count and not stripped[count].isspace():
|
|
46
|
+
return line
|
|
47
|
+
return f"{line[:indent]}{'#' * min(6, count + levels)}{stripped[count:]}"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import abc
|
|
2
2
|
import dataclasses
|
|
3
|
+
import json
|
|
3
4
|
import math
|
|
4
5
|
import pathlib
|
|
5
6
|
import shlex
|
|
@@ -41,6 +42,10 @@ class InputControl(abc.ABC):
|
|
|
41
42
|
"""Flags for this control."""
|
|
42
43
|
return set()
|
|
43
44
|
|
|
45
|
+
def allows_repeated_values(self) -> bool:
|
|
46
|
+
"""Whether this control accepts repeated CLI values for the same option."""
|
|
47
|
+
return False
|
|
48
|
+
|
|
44
49
|
@abc.abstractmethod
|
|
45
50
|
def parse(self, args: _parse.ParsedArgs) -> ParseResult | ParseError | None:
|
|
46
51
|
"""Parse from CLI args. Returns value, ParseError, or None if not provided."""
|
|
@@ -49,7 +54,7 @@ class InputControl(abc.ABC):
|
|
|
49
54
|
"""Parse a value supplied by a URL query parameter."""
|
|
50
55
|
|
|
51
56
|
result = self.parse(
|
|
52
|
-
_parse.ParsedArgs(options={self.option: value}, unexpected=[])
|
|
57
|
+
_parse.ParsedArgs(options={self.option: [value]}, unexpected=[])
|
|
53
58
|
)
|
|
54
59
|
if result is None:
|
|
55
60
|
raise RuntimeError(
|
|
@@ -82,7 +87,7 @@ class InputControl(abc.ABC):
|
|
|
82
87
|
def prompt_interactive(
|
|
83
88
|
self, effective_default: typing.Any = _UNSET
|
|
84
89
|
) -> dict[str, str | None]:
|
|
85
|
-
"""Prompt the user for a value. Returns
|
|
90
|
+
"""Prompt the user for a value. Returns option values to inject into args.
|
|
86
91
|
|
|
87
92
|
effective_default overrides self.default for display when the caller
|
|
88
93
|
has a better default (e.g. from a preset).
|
|
@@ -97,7 +102,7 @@ class FlagControl(InputControl):
|
|
|
97
102
|
return {self.option}
|
|
98
103
|
|
|
99
104
|
def parse(self, args: _parse.ParsedArgs) -> ParseResult | None:
|
|
100
|
-
return ParseResult(not self.default) if self.option
|
|
105
|
+
return ParseResult(not self.default) if args.has(self.option) else None
|
|
101
106
|
|
|
102
107
|
def parse_query_value(self, value: str) -> ParseResult | ParseError:
|
|
103
108
|
match value.lower():
|
|
@@ -164,7 +169,7 @@ class TextControl(ValueControl):
|
|
|
164
169
|
default: str
|
|
165
170
|
|
|
166
171
|
def parse(self, args: _parse.ParsedArgs) -> ParseResult | ParseError | None:
|
|
167
|
-
res = args.
|
|
172
|
+
res = args.value_for(self.option)
|
|
168
173
|
return None if res is None else ParseResult(res)
|
|
169
174
|
|
|
170
175
|
def strategy(self) -> st.SearchStrategy:
|
|
@@ -217,6 +222,98 @@ class FileControl(TextControl):
|
|
|
217
222
|
return result
|
|
218
223
|
|
|
219
224
|
|
|
225
|
+
@dataclasses.dataclass
|
|
226
|
+
class MultiFileControl(ValueControl):
|
|
227
|
+
default: list[str]
|
|
228
|
+
|
|
229
|
+
def allows_repeated_values(self) -> bool:
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
def parse(self, args: _parse.ParsedArgs) -> ParseResult | ParseError | None:
|
|
233
|
+
values = args.values_for(self.option)
|
|
234
|
+
if not values:
|
|
235
|
+
return None
|
|
236
|
+
flattened = [
|
|
237
|
+
part
|
|
238
|
+
for value in values
|
|
239
|
+
for part in (
|
|
240
|
+
value.splitlines()
|
|
241
|
+
if isinstance(value, str) and "\n" in value
|
|
242
|
+
else [value]
|
|
243
|
+
)
|
|
244
|
+
if part or part is None
|
|
245
|
+
]
|
|
246
|
+
paths: list[str] = []
|
|
247
|
+
for value in flattened:
|
|
248
|
+
if value is None:
|
|
249
|
+
return ParseError(f"Option {self.option} requires a value")
|
|
250
|
+
if value and not pathlib.Path(value).exists():
|
|
251
|
+
return ParseError(f"File not found: {value!r}")
|
|
252
|
+
paths.append(value)
|
|
253
|
+
return ParseResult(paths)
|
|
254
|
+
|
|
255
|
+
def parse_query_value(self, value: str) -> ParseResult | ParseError:
|
|
256
|
+
try:
|
|
257
|
+
raw: typing.Any = json.loads(value)
|
|
258
|
+
except json.JSONDecodeError:
|
|
259
|
+
raw = [value] if value else []
|
|
260
|
+
if not isinstance(raw, list):
|
|
261
|
+
return ParseError(
|
|
262
|
+
f"Query parameter for {self.option} must be a JSON list of paths"
|
|
263
|
+
)
|
|
264
|
+
paths: list[str] = []
|
|
265
|
+
for item in typing.cast(list[typing.Any], raw):
|
|
266
|
+
if not isinstance(item, str):
|
|
267
|
+
return ParseError(
|
|
268
|
+
f"Query parameter for {self.option} must be a JSON list of paths"
|
|
269
|
+
)
|
|
270
|
+
if item and not pathlib.Path(item).exists():
|
|
271
|
+
return ParseError(f"File not found: {item!r}")
|
|
272
|
+
paths.append(item)
|
|
273
|
+
return ParseResult(paths)
|
|
274
|
+
|
|
275
|
+
def strategy(self) -> st.SearchStrategy:
|
|
276
|
+
return st.lists(st.text())
|
|
277
|
+
|
|
278
|
+
def format_usage_parts(self) -> list[str]:
|
|
279
|
+
return [f"[{self.option} {self.metavar} ...]"]
|
|
280
|
+
|
|
281
|
+
def format_help_lines(self) -> list[str]:
|
|
282
|
+
line = f" {self.option} {self.metavar}: {self.help_text}"
|
|
283
|
+
if self.default:
|
|
284
|
+
line += f" (default: {', '.join(self.default)})"
|
|
285
|
+
line += f" (repeat {self.option} to select multiple files)"
|
|
286
|
+
return [line]
|
|
287
|
+
|
|
288
|
+
def format_value(self, value: typing.Any) -> list[str]:
|
|
289
|
+
values = list(value)
|
|
290
|
+
if values == self.default:
|
|
291
|
+
return []
|
|
292
|
+
return [f"{self.option} {shlex.quote(v)}" for v in values]
|
|
293
|
+
|
|
294
|
+
def format_query_value(self, value: typing.Any) -> str | None:
|
|
295
|
+
values = list(value)
|
|
296
|
+
return None if values == self.default else json.dumps(values)
|
|
297
|
+
|
|
298
|
+
def prompt_interactive(
|
|
299
|
+
self, effective_default: typing.Any = _UNSET
|
|
300
|
+
) -> dict[str, str | None]:
|
|
301
|
+
d = self.default if effective_default is _UNSET else effective_default
|
|
302
|
+
default_display = f" [{', '.join(d)}]" if d else ""
|
|
303
|
+
while True:
|
|
304
|
+
response = input(
|
|
305
|
+
f"{self.help_text} (comma-separated paths){default_display}: "
|
|
306
|
+
)
|
|
307
|
+
if not response:
|
|
308
|
+
return {}
|
|
309
|
+
paths = [part.strip() for part in response.split(",") if part.strip()]
|
|
310
|
+
missing = [path for path in paths if not pathlib.Path(path).exists()]
|
|
311
|
+
if missing:
|
|
312
|
+
print(f"File not found: {missing[0]!r}")
|
|
313
|
+
continue
|
|
314
|
+
return {self.option: "\n".join(paths)}
|
|
315
|
+
|
|
316
|
+
|
|
220
317
|
@dataclasses.dataclass
|
|
221
318
|
class TextAreaControl(ValueControl):
|
|
222
319
|
default: str
|
|
@@ -229,15 +326,15 @@ class TextAreaControl(ValueControl):
|
|
|
229
326
|
return {self._stdin_flag}
|
|
230
327
|
|
|
231
328
|
def parse(self, args: _parse.ParsedArgs) -> ParseResult | ParseError | None:
|
|
232
|
-
if not mo.running_in_notebook() and self._stdin_flag
|
|
233
|
-
if args.
|
|
329
|
+
if not mo.running_in_notebook() and args.has(self._stdin_flag):
|
|
330
|
+
if args.value_for(self._stdin_flag) is not None:
|
|
234
331
|
return None
|
|
235
|
-
if self.option
|
|
332
|
+
if args.has(self.option):
|
|
236
333
|
return ParseError(
|
|
237
334
|
f"Cannot use both {self.option} and {self._stdin_flag}"
|
|
238
335
|
)
|
|
239
336
|
return ParseResult(sys.stdin.read())
|
|
240
|
-
res = args.
|
|
337
|
+
res = args.value_for(self.option)
|
|
241
338
|
return None if res is None else ParseResult(res)
|
|
242
339
|
|
|
243
340
|
def strategy(self) -> st.SearchStrategy:
|
|
@@ -273,7 +370,7 @@ class NumberControl(ValueControl):
|
|
|
273
370
|
default: Numeric | None
|
|
274
371
|
|
|
275
372
|
def parse(self, args: _parse.ParsedArgs) -> ParseResult | ParseError | None:
|
|
276
|
-
value = args.
|
|
373
|
+
value = args.value_for(self.option)
|
|
277
374
|
return None if value is None else _parse_number(self.option, value)
|
|
278
375
|
|
|
279
376
|
def strategy(self) -> st.SearchStrategy:
|
|
@@ -340,7 +437,7 @@ class RangeControl(ValueControl):
|
|
|
340
437
|
)
|
|
341
438
|
|
|
342
439
|
def parse(self, args: _parse.ParsedArgs) -> ParseResult | ParseError | None:
|
|
343
|
-
raw = args.
|
|
440
|
+
raw = args.value_for(self.option)
|
|
344
441
|
if raw is None:
|
|
345
442
|
return None
|
|
346
443
|
values = raw.split(",")
|
|
@@ -460,11 +557,11 @@ class DropdownControl(InputControl):
|
|
|
460
557
|
|
|
461
558
|
def parse(self, args: _parse.ParsedArgs) -> ParseResult | ParseError | None:
|
|
462
559
|
no_flag = self._no_flag
|
|
463
|
-
if no_flag and
|
|
464
|
-
if self.option
|
|
560
|
+
if no_flag and args.has(no_flag):
|
|
561
|
+
if args.has(self.option):
|
|
465
562
|
return ParseError(f"Cannot use both {self.option} and {no_flag}")
|
|
466
563
|
return ParseResult(None)
|
|
467
|
-
raw = args.
|
|
564
|
+
raw = args.value_for(self.option)
|
|
468
565
|
if raw is None:
|
|
469
566
|
return None
|
|
470
567
|
if raw not in self.allowed_values:
|
|
@@ -9,22 +9,35 @@ interactive_flag = "--interactive"
|
|
|
9
9
|
|
|
10
10
|
@dataclasses.dataclass
|
|
11
11
|
class ParsedArgs:
|
|
12
|
-
options: dict[str, str | None]
|
|
12
|
+
options: dict[str, list[str | None]]
|
|
13
13
|
unexpected: list[str]
|
|
14
14
|
|
|
15
|
+
def values_for(self, option: str) -> list[str | None]:
|
|
16
|
+
return self.options.get(option, [])
|
|
17
|
+
|
|
18
|
+
def value_for(self, option: str) -> str | None:
|
|
19
|
+
values = self.values_for(option)
|
|
20
|
+
return values[-1] if values else None
|
|
21
|
+
|
|
22
|
+
def has(self, option: str) -> bool:
|
|
23
|
+
return option in self.options
|
|
24
|
+
|
|
25
|
+
def set_value(self, option: str, value: str | None) -> None:
|
|
26
|
+
self.options[option] = [value]
|
|
27
|
+
|
|
15
28
|
@property
|
|
16
29
|
def is_help(self) -> bool:
|
|
17
|
-
return any(
|
|
30
|
+
return any(self.has(x) for x in help_flags)
|
|
18
31
|
|
|
19
32
|
@property
|
|
20
33
|
def is_interactive(self) -> bool:
|
|
21
|
-
return
|
|
34
|
+
return self.has(interactive_flag)
|
|
22
35
|
|
|
23
36
|
@classmethod
|
|
24
37
|
def from_options(cls, args: list[str]) -> "ParsedArgs":
|
|
25
38
|
"""Parse a pre-tokenized list of options (no command name)."""
|
|
26
39
|
|
|
27
|
-
options: dict[str, str | None] = {}
|
|
40
|
+
options: dict[str, list[str | None]] = {}
|
|
28
41
|
unexpected: list[str] = []
|
|
29
42
|
prev = None
|
|
30
43
|
for arg in args:
|
|
@@ -32,13 +45,13 @@ class ParsedArgs:
|
|
|
32
45
|
if arg.startswith("-") and not (prev is not None and is_negative_num):
|
|
33
46
|
if "=" in arg:
|
|
34
47
|
key, value = arg.split("=", 1)
|
|
35
|
-
options[
|
|
48
|
+
options.setdefault(key, []).append(value)
|
|
36
49
|
prev = None
|
|
37
50
|
else:
|
|
38
|
-
options[
|
|
51
|
+
options.setdefault(arg, []).append(None)
|
|
39
52
|
prev = arg
|
|
40
53
|
elif prev is not None and prev.startswith("-"):
|
|
41
|
-
options[prev] = arg
|
|
54
|
+
options[prev][-1] = arg
|
|
42
55
|
prev = None
|
|
43
56
|
else:
|
|
44
57
|
unexpected.append(arg)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
import marimo as mo
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _Embed(typing.Protocol):
|
|
8
|
+
defs: typing.Mapping[str, typing.Any]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _App(typing.Protocol):
|
|
12
|
+
def clone(self) -> "_App": ...
|
|
13
|
+
|
|
14
|
+
async def embed(self, defs: dict[str, typing.Any] | None = None) -> typing.Any: ...
|
|
15
|
+
|
|
16
|
+
def run(
|
|
17
|
+
self, defs: dict[str, typing.Any]
|
|
18
|
+
) -> tuple[typing.Iterable[typing.Any], typing.Mapping[str, object]]: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class App:
|
|
22
|
+
"""
|
|
23
|
+
Wrap a marimo app with lean script-mode embeds.
|
|
24
|
+
|
|
25
|
+
In script mode, only the embedded notebook's ``result`` definition is
|
|
26
|
+
retained, so intermediate definitions and rendered outputs can be released
|
|
27
|
+
after the embed completes.
|
|
28
|
+
|
|
29
|
+
This also works around marimo nested embed failures in script mode,
|
|
30
|
+
see https://github.com/marimo-team/marimo/issues/9572
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, app: _App) -> None:
|
|
34
|
+
self._app = app
|
|
35
|
+
|
|
36
|
+
def clone(self) -> "App":
|
|
37
|
+
return App(self._app.clone())
|
|
38
|
+
|
|
39
|
+
async def embed(self, defs: dict[str, typing.Any] | None = None) -> typing.Any:
|
|
40
|
+
if mo.running_in_notebook():
|
|
41
|
+
return await self._app.embed(defs=defs)
|
|
42
|
+
return await asyncio.to_thread(_embed_in_script, self._app, defs or {})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Passthrough:
|
|
46
|
+
"""
|
|
47
|
+
Override an inner embed with the results of an existing embed.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, source: _Embed | dict[str, typing.Any]) -> None:
|
|
51
|
+
self.defs = {
|
|
52
|
+
"result": (source if isinstance(source, dict) else source.defs)["result"],
|
|
53
|
+
"interface": None,
|
|
54
|
+
}
|
|
55
|
+
self.output = None
|
|
56
|
+
|
|
57
|
+
async def embed(self, defs: dict[str, typing.Any]) -> "Passthrough":
|
|
58
|
+
unexpected = defs.keys() - {"args"}
|
|
59
|
+
if unexpected:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"moops.embed.Passthrough received unexpected defs keys: {unexpected}"
|
|
62
|
+
)
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _embed_in_script(app: _App, defs: dict[str, typing.Any]) -> typing.Any:
|
|
67
|
+
_, computed_defs = app.run(defs=defs)
|
|
68
|
+
return Passthrough(dict(computed_defs))
|
|
@@ -10,7 +10,7 @@ import weakref
|
|
|
10
10
|
|
|
11
11
|
import marimo as mo
|
|
12
12
|
|
|
13
|
-
from . import _input_map, _naming, _options, _parse, _query_params, interface
|
|
13
|
+
from . import _input_map, _markdown, _naming, _options, _parse, _query_params, interface
|
|
14
14
|
from .interface import FileBrowserWithInitialSelection
|
|
15
15
|
from .presets import Presets
|
|
16
16
|
|
|
@@ -43,6 +43,7 @@ class Group:
|
|
|
43
43
|
self._default_preset_state = self._build_default_preset_state()
|
|
44
44
|
self._active_preset = self._build_active_preset()
|
|
45
45
|
self._parent_group: Group | None = None
|
|
46
|
+
self._markdown_heading_offset = 0
|
|
46
47
|
self._subgroup_interfaces: dict[
|
|
47
48
|
str, weakref.ReferenceType[interface.Interface]
|
|
48
49
|
] = {}
|
|
@@ -58,6 +59,7 @@ class Group:
|
|
|
58
59
|
prefix: str,
|
|
59
60
|
overrides: dict[str, typing.Any] | None = None,
|
|
60
61
|
presets: Presets | None = None,
|
|
62
|
+
markdown_heading_offset: int = 1,
|
|
61
63
|
) -> Group:
|
|
62
64
|
"""Create a child Group that prefixes all its option names with '{prefix}-'.
|
|
63
65
|
|
|
@@ -67,11 +69,20 @@ class Group:
|
|
|
67
69
|
|
|
68
70
|
Pass `presets=` to give the subgroup its own preset selector; otherwise
|
|
69
71
|
it inherits the parent's preset state.
|
|
72
|
+
|
|
73
|
+
Markdown headings emitted by the subgroup are demoted by one level by
|
|
74
|
+
default. Pass `markdown_heading_offset=` to customize how many levels
|
|
75
|
+
this subgroup adds relative to its parent.
|
|
70
76
|
"""
|
|
77
|
+
if markdown_heading_offset < 0:
|
|
78
|
+
raise ValueError("markdown_heading_offset must be non-negative")
|
|
71
79
|
child = type(self)([prefix])
|
|
72
80
|
child._state = self._state
|
|
73
81
|
child._cli_map = _input_map.InputMap()
|
|
74
82
|
child._parent_group = self
|
|
83
|
+
child._markdown_heading_offset = (
|
|
84
|
+
self._markdown_heading_offset + markdown_heading_offset
|
|
85
|
+
)
|
|
75
86
|
child._overrides = {**self._overrides.get(prefix, {}), **(overrides or {})}
|
|
76
87
|
child.option = f"{self.option}-{prefix}" if self.option else f"--{prefix}"
|
|
77
88
|
child._presets = presets
|
|
@@ -193,9 +204,12 @@ class Group:
|
|
|
193
204
|
self._subgroup_interfaces = live_refs
|
|
194
205
|
return missing
|
|
195
206
|
|
|
196
|
-
def md(self, text: str) -> mo.Html | None:
|
|
207
|
+
def md(self, text: str, *, notebook_only: bool = False) -> mo.Html | None:
|
|
197
208
|
"""Display markdown in notebooks or plain text in CLI."""
|
|
198
209
|
|
|
210
|
+
if notebook_only and not mo.running_in_notebook():
|
|
211
|
+
return None
|
|
212
|
+
text = _markdown.demote_markdown_headings(text, self._markdown_heading_offset)
|
|
199
213
|
if mo.running_in_notebook():
|
|
200
214
|
return mo.md(text)
|
|
201
215
|
if self._state.args.is_help:
|
|
@@ -332,39 +346,44 @@ class Group:
|
|
|
332
346
|
help_text: str,
|
|
333
347
|
label: str | None = None,
|
|
334
348
|
multiple: bool = True,
|
|
335
|
-
on_change: typing.Callable[[
|
|
349
|
+
on_change: typing.Callable[[typing.Any], None] | None = None,
|
|
336
350
|
**kwargs: typing.Any,
|
|
337
351
|
) -> FileBrowserWithInitialSelection | mo.ui.file_browser:
|
|
338
352
|
"""Create a file browser UI element that maps to a CLI path option."""
|
|
339
|
-
if multiple:
|
|
340
|
-
raise NotImplementedError("multiple=True is not yet supported")
|
|
341
|
-
|
|
342
353
|
opt = self._make_opt(label=label, option=option)
|
|
343
354
|
initial_path = str(initial_path)
|
|
344
|
-
|
|
345
|
-
option
|
|
346
|
-
metavar
|
|
347
|
-
help_text
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
355
|
+
ctrl_opts = {
|
|
356
|
+
"option": opt.option,
|
|
357
|
+
"metavar": "PATH",
|
|
358
|
+
"help_text": help_text,
|
|
359
|
+
}
|
|
360
|
+
if multiple:
|
|
361
|
+
default: str | list[str] = [initial_path] if initial_path else []
|
|
362
|
+
cli = _options.MultiFileControl(default=default, **ctrl_opts)
|
|
363
|
+
else:
|
|
364
|
+
default = initial_path
|
|
365
|
+
cli = _options.FileControl(default=default, **ctrl_opts)
|
|
366
|
+
value = self._get_value(cli, default)
|
|
351
367
|
raw_on_change = self._query_on_change(cli, on_change)
|
|
352
368
|
|
|
353
369
|
def _on_change(infos: typing.Sequence[interface.FileBrowserFileInfo]) -> None:
|
|
354
370
|
if raw_on_change is not None:
|
|
355
|
-
|
|
371
|
+
paths = [str(info.path) for info in infos]
|
|
372
|
+
raw_on_change(paths if multiple else (paths[0] if paths else ""))
|
|
356
373
|
|
|
357
|
-
|
|
374
|
+
paths = list(value) if multiple else ([value] if value else [])
|
|
375
|
+
first = paths[0] if paths else ""
|
|
376
|
+
p = pathlib.Path(first) if first else None
|
|
358
377
|
browser_kwargs: dict[str, typing.Any] = dict(
|
|
359
|
-
initial_path=str(p.parent) if (p and p.is_file()) else
|
|
378
|
+
initial_path=str(p.parent) if (p and p.is_file()) else first,
|
|
360
379
|
label=opt.label_with_tooltip(help_text),
|
|
361
380
|
multiple=multiple,
|
|
362
381
|
on_change=_on_change,
|
|
363
382
|
**kwargs,
|
|
364
383
|
)
|
|
365
384
|
return self._cli_map.register(
|
|
366
|
-
FileBrowserWithInitialSelection(default=
|
|
367
|
-
if
|
|
385
|
+
FileBrowserWithInitialSelection(default=paths, **browser_kwargs)
|
|
386
|
+
if paths
|
|
368
387
|
else mo.ui.file_browser(**browser_kwargs),
|
|
369
388
|
cli,
|
|
370
389
|
)
|
|
@@ -603,12 +622,12 @@ class Group:
|
|
|
603
622
|
case _:
|
|
604
623
|
pass
|
|
605
624
|
try:
|
|
606
|
-
|
|
607
|
-
control.prompt_interactive(effective_default)
|
|
608
|
-
)
|
|
625
|
+
prompted = control.prompt_interactive(effective_default)
|
|
609
626
|
except KeyboardInterrupt:
|
|
610
627
|
print("\nAborted.")
|
|
611
628
|
sys.exit(1)
|
|
629
|
+
for option, value in prompted.items():
|
|
630
|
+
self._state.args.set_value(option, value)
|
|
612
631
|
match control.parse(self._state.args):
|
|
613
632
|
case _options.ParseResult(value=v):
|
|
614
633
|
return v
|
|
@@ -53,22 +53,27 @@ class Interface:
|
|
|
53
53
|
|
|
54
54
|
def validate(self, state: _parse.ParseState) -> typing.Iterator[str]:
|
|
55
55
|
flags: set[str] = set()
|
|
56
|
-
value_options:
|
|
56
|
+
value_options: dict[str, _options.InputControl] = {}
|
|
57
57
|
for cli in self._all_input_controls():
|
|
58
58
|
flags.update(cli.flags())
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
for option in cli.options():
|
|
60
|
+
value_options[option] = cli
|
|
61
|
+
rendered = flags | set(value_options)
|
|
61
62
|
yield from (v for k, v in state.validation_errors.items() if k in rendered)
|
|
62
63
|
unexp_text = "Unexpected argument: "
|
|
63
64
|
for x in state.args.unexpected:
|
|
64
65
|
yield f"{unexp_text}{x}"
|
|
65
|
-
for k,
|
|
66
|
+
for k, values in state.args.options.items():
|
|
66
67
|
if k in flags:
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
for v in values:
|
|
69
|
+
if v is not None:
|
|
70
|
+
yield f"{k} does not take a value, but was given: {v}"
|
|
69
71
|
elif k in value_options:
|
|
70
|
-
if
|
|
71
|
-
yield f"
|
|
72
|
+
if len(values) > 1 and not value_options[k].allows_repeated_values():
|
|
73
|
+
yield f"{k} was provided multiple times"
|
|
74
|
+
for v in values:
|
|
75
|
+
if v is None:
|
|
76
|
+
yield f"Option {k} requires a value"
|
|
72
77
|
elif k not in _parse.help_flags and k != _parse.interactive_flag:
|
|
73
78
|
yield f"{unexp_text}{k}"
|
|
74
79
|
|
|
@@ -332,19 +337,22 @@ class _PresetsUI:
|
|
|
332
337
|
class FileBrowserWithInitialSelection(mo.ui.file_browser):
|
|
333
338
|
"""Extends mo.ui.file_browser with a CLI path fallback when no file is selected."""
|
|
334
339
|
|
|
335
|
-
def __init__(
|
|
336
|
-
self.
|
|
340
|
+
def __init__(
|
|
341
|
+
self, default: str | typing.Sequence[str], **kwargs: typing.Any
|
|
342
|
+
) -> None:
|
|
343
|
+
self._default = [default] if isinstance(default, str) else list(default)
|
|
337
344
|
super().__init__(**kwargs)
|
|
338
345
|
|
|
339
346
|
@property
|
|
340
347
|
def value(self) -> list[FileBrowserFileInfo]: # type: ignore[override]
|
|
341
348
|
if browser_value := list(super().value):
|
|
342
349
|
return browser_value
|
|
343
|
-
p = pathlib.Path(self._default)
|
|
344
350
|
return [
|
|
345
351
|
FileBrowserFileInfo(
|
|
346
|
-
id=
|
|
352
|
+
id=default, path=p, name=p.name, is_directory=p.is_dir()
|
|
347
353
|
)
|
|
354
|
+
for default in self._default
|
|
355
|
+
for p in [pathlib.Path(default)]
|
|
348
356
|
]
|
|
349
357
|
|
|
350
358
|
@value.setter
|
|
@@ -353,13 +361,17 @@ class FileBrowserWithInitialSelection(mo.ui.file_browser):
|
|
|
353
361
|
raise RuntimeError("Setting the value of a UIElement is not allowed.")
|
|
354
362
|
|
|
355
363
|
def _mime_(self) -> tuple[str, str]: # type: ignore[override]
|
|
364
|
+
files = "\n".join({f"- `{p}`" for p in self._default})
|
|
356
365
|
return mo.vstack(
|
|
357
366
|
[
|
|
358
367
|
mo.Html(super()._mime_()[1]),
|
|
359
368
|
mo.callout(
|
|
360
369
|
mo.md(
|
|
361
|
-
"marimo's file browser
|
|
362
|
-
|
|
370
|
+
"marimo's file browser "
|
|
371
|
+
"[does not yet support an initial selection]"
|
|
372
|
+
"(https://github.com/marimo-team/marimo/issues/7468). "
|
|
373
|
+
"Falling back to:\n\n"
|
|
374
|
+
f"{files}"
|
|
363
375
|
),
|
|
364
376
|
kind="info",
|
|
365
377
|
),
|
|
@@ -404,6 +416,9 @@ class CustomControl(UIElement[typing.Any, typing.Any]):
|
|
|
404
416
|
|
|
405
417
|
def _ctrl_value(ctrl: typing.Any) -> typing.Any:
|
|
406
418
|
if isinstance(ctrl, mo.ui.file_browser):
|
|
419
|
+
multiple = getattr(ctrl, "_component_args", {}).get("multiple", True)
|
|
420
|
+
if multiple:
|
|
421
|
+
return [str(info.path) for info in ctrl.value]
|
|
407
422
|
p = ctrl.path()
|
|
408
423
|
return str(p) if p is not None else ""
|
|
409
424
|
return ctrl._selected_key if hasattr(ctrl, "_selected_key") else ctrl.value
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import inspect
|
|
1
2
|
import json
|
|
2
3
|
import pathlib
|
|
3
4
|
import typing
|
|
@@ -8,11 +9,14 @@ class Presets:
|
|
|
8
9
|
|
|
9
10
|
def __init__(
|
|
10
11
|
self,
|
|
11
|
-
filename: str | pathlib.Path,
|
|
12
12
|
get_selected_preset: typing.Callable[[], str | None],
|
|
13
13
|
set_selected_preset: typing.Callable[[str | None], None],
|
|
14
|
+
*,
|
|
15
|
+
filename: str | pathlib.Path | None = None,
|
|
14
16
|
) -> None:
|
|
15
|
-
self._filename =
|
|
17
|
+
self._filename = (
|
|
18
|
+
pathlib.Path(filename) if filename is not None else _infer_filename()
|
|
19
|
+
)
|
|
16
20
|
self.get_current = get_selected_preset
|
|
17
21
|
self.select = set_selected_preset
|
|
18
22
|
if self._filename.exists():
|
|
@@ -64,3 +68,32 @@ class Presets:
|
|
|
64
68
|
def _write(self) -> None:
|
|
65
69
|
with self._filename.open("w") as f:
|
|
66
70
|
json.dump({"presets": self._data}, f, indent=2)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _infer_filename() -> pathlib.Path:
|
|
74
|
+
caller = _marimo_notebook_filename() or _stack_filename()
|
|
75
|
+
return caller.with_name(f"{caller.stem}_presets.json")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _marimo_notebook_filename() -> pathlib.Path | None:
|
|
79
|
+
try:
|
|
80
|
+
from marimo._runtime.context import ContextNotInitializedError, get_context
|
|
81
|
+
except ImportError:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
filename = get_context().filename
|
|
86
|
+
except ContextNotInitializedError:
|
|
87
|
+
return None
|
|
88
|
+
if filename is None:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
path = pathlib.Path(filename)
|
|
92
|
+
return None if path.name.startswith("<") else path
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _stack_filename() -> pathlib.Path:
|
|
96
|
+
caller = pathlib.Path(inspect.stack()[2].filename)
|
|
97
|
+
if caller.name.startswith("<"):
|
|
98
|
+
raise ValueError("Presets filename could not be inferred; pass a filename")
|
|
99
|
+
return caller
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|