moops 0.2.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moops
3
- Version: 0.2.0
3
+ Version: 0.3.0
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
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "moops"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Write Marimo notebooks that also work as CLI scripts, with unified UI controls"
9
9
  license = "MIT"
10
10
  readme = "README.md"
@@ -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 entries to inject into args.options.
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 in args.options else None
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.options.get(self.option)
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 in args.options:
233
- if args.options[self._stdin_flag] is not None:
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 in args.options:
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.options.get(self.option)
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.options.get(self.option)
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.options.get(self.option)
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 no_flag in args.options:
464
- if self.option in args.options:
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.options.get(self.option)
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(x in self.options for x in help_flags)
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 interactive_flag in self.options
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[key] = value
48
+ options.setdefault(key, []).append(value)
36
49
  prev = None
37
50
  else:
38
- options[arg] = None
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)
@@ -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[[str], None] | None = None,
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
- cli = _options.FileControl(
345
- option=opt.option,
346
- metavar="PATH",
347
- help_text=help_text,
348
- default=initial_path,
349
- )
350
- value = self._get_value(cli, initial_path)
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
- raw_on_change(str(infos[0].path) if infos else "")
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
- p = pathlib.Path(value) if value else None
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 (value or ""),
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=value, **browser_kwargs)
367
- if value
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
- self._state.args.options.update(
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: set[str] = set()
56
+ value_options: dict[str, _options.InputControl] = {}
57
57
  for cli in self._all_input_controls():
58
58
  flags.update(cli.flags())
59
- value_options.update(cli.options())
60
- rendered = flags | value_options
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, v in state.args.options.items():
66
+ for k, values in state.args.options.items():
66
67
  if k in flags:
67
- if v is not None:
68
- yield f"{k} does not take a value, but was given: {v}"
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 v is None:
71
- yield f"Option {k} requires a value"
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__(self, default: str, **kwargs: typing.Any) -> None:
336
- self._default = default
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=self._default, path=p, name=p.name, is_directory=p.is_dir()
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 does not support "
362
- f"an initial selection falling back to `{self._default}`"
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 = pathlib.Path(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