moops 0.13.2__tar.gz → 0.13.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {moops-0.13.2 → moops-0.13.4}/PKG-INFO +13 -2
  2. {moops-0.13.2 → moops-0.13.4}/README.md +9 -1
  3. {moops-0.13.2 → moops-0.13.4}/pyproject.toml +5 -1
  4. {moops-0.13.2 → moops-0.13.4}/src/moops/_list_options.py +13 -1
  5. {moops-0.13.2 → moops-0.13.4}/src/moops/_options.py +28 -2
  6. moops-0.13.4/src/moops/_status.py +265 -0
  7. {moops-0.13.2 → moops-0.13.4}/src/moops/_text_wrap.py +4 -2
  8. {moops-0.13.2 → moops-0.13.4}/src/moops/group.py +43 -0
  9. {moops-0.13.2 → moops-0.13.4}/src/moops/interface.py +35 -8
  10. {moops-0.13.2 → moops-0.13.4}/src/moops/__init__.py +0 -0
  11. {moops-0.13.2 → moops-0.13.4}/src/moops/_choice_options.py +0 -0
  12. {moops-0.13.2 → moops-0.13.4}/src/moops/_control_mirroring.py +0 -0
  13. {moops-0.13.2 → moops-0.13.4}/src/moops/_custom_element.py +0 -0
  14. {moops-0.13.2 → moops-0.13.4}/src/moops/_embed.py +0 -0
  15. {moops-0.13.2 → moops-0.13.4}/src/moops/_input_map.py +0 -0
  16. {moops-0.13.2 → moops-0.13.4}/src/moops/_interface_utils.py +0 -0
  17. {moops-0.13.2 → moops-0.13.4}/src/moops/_list_controls.py +0 -0
  18. {moops-0.13.2 → moops-0.13.4}/src/moops/_marimo_controls.py +0 -0
  19. {moops-0.13.2 → moops-0.13.4}/src/moops/_markdown.py +0 -0
  20. {moops-0.13.2 → moops-0.13.4}/src/moops/_naming.py +0 -0
  21. {moops-0.13.2 → moops-0.13.4}/src/moops/_parse.py +0 -0
  22. {moops-0.13.2 → moops-0.13.4}/src/moops/_preset_state.py +0 -0
  23. {moops-0.13.2 → moops-0.13.4}/src/moops/_presets_ui.py +0 -0
  24. {moops-0.13.2 → moops-0.13.4}/src/moops/_query_params.py +0 -0
  25. {moops-0.13.2 → moops-0.13.4}/src/moops/_run.py +0 -0
  26. {moops-0.13.2 → moops-0.13.4}/src/moops/_run_button.py +0 -0
  27. {moops-0.13.2 → moops-0.13.4}/src/moops/_subgroup_registry.py +0 -0
  28. {moops-0.13.2 → moops-0.13.4}/src/moops/_terminal_graphics.py +0 -0
  29. {moops-0.13.2 → moops-0.13.4}/src/moops/_ui_workarounds.py +0 -0
  30. {moops-0.13.2 → moops-0.13.4}/src/moops/_value_resolution.py +0 -0
  31. {moops-0.13.2 → moops-0.13.4}/src/moops/_variant.py +0 -0
  32. {moops-0.13.2 → moops-0.13.4}/src/moops/presets.py +0 -0
  33. {moops-0.13.2 → moops-0.13.4}/src/moops/workarounds.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moops
3
- Version: 0.13.2
3
+ Version: 0.13.4
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
@@ -13,12 +13,15 @@ Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Requires-Dist: marimo>=0.23.1
16
+ Requires-Dist: tqdm>=4.67.1 ; extra == 'status'
16
17
  Requires-Dist: tabulate>=0.10.0 ; extra == 'table'
17
18
  Requires-Dist: hypothesis ; extra == 'test'
18
19
  Requires-Python: >=3.10
19
20
  Project-URL: Homepage, https://github.com/yairchu/moops
20
21
  Project-URL: Repository, https://github.com/yairchu/moops
21
22
  Project-URL: Issues, https://github.com/yairchu/moops/issues
23
+ Project-URL: Agent skill, https://github.com/yairchu/moops/tree/main/skills/migrate-marimo-to-moops
24
+ Provides-Extra: status
22
25
  Provides-Extra: table
23
26
  Provides-Extra: test
24
27
  Description-Content-Type: text/markdown
@@ -47,6 +50,14 @@ Using `moops`, both implementations are merged into one.
47
50
 
48
51
  Now your notebook doubles as a CLI script
49
52
 
53
+ ## Agent-assisted migrations
54
+
55
+ If you use a coding agent, moops includes an agent skill for migrating existing
56
+ marimo notebooks: [`migrate-marimo-to-moops`](skills/migrate-marimo-to-moops/SKILL.md).
57
+
58
+ Install or point your agent at that skill, then ask it to migrate a notebook to
59
+ moops while preserving the notebook's behavior.
60
+
50
61
  ## Running notebooks from Python
51
62
 
52
63
  Notebooks can also be called from Python with `moops.run`.
@@ -196,7 +207,7 @@ def test_name_casing_preserves_alphanumeric_count(kwargs):
196
207
 
197
208
  The `examples/` directory is grouped by topic:
198
209
 
199
- * `basics/` — small notebooks covering options, flags, and file inputs
210
+ * `basics/` — small notebooks covering options, flags, file inputs, and status output
200
211
  * `custom_controls/` — `args.custom()` and mirroring it via `controls_from`
201
212
  * `composition/` — embedding and varying notebooks (`embed`, `variant_embed`)
202
213
  * `game_of_life/` — a worked multi-notebook example
@@ -22,6 +22,14 @@ Using `moops`, both implementations are merged into one.
22
22
 
23
23
  Now your notebook doubles as a CLI script
24
24
 
25
+ ## Agent-assisted migrations
26
+
27
+ If you use a coding agent, moops includes an agent skill for migrating existing
28
+ marimo notebooks: [`migrate-marimo-to-moops`](skills/migrate-marimo-to-moops/SKILL.md).
29
+
30
+ Install or point your agent at that skill, then ask it to migrate a notebook to
31
+ moops while preserving the notebook's behavior.
32
+
25
33
  ## Running notebooks from Python
26
34
 
27
35
  Notebooks can also be called from Python with `moops.run`.
@@ -171,7 +179,7 @@ def test_name_casing_preserves_alphanumeric_count(kwargs):
171
179
 
172
180
  The `examples/` directory is grouped by topic:
173
181
 
174
- * `basics/` — small notebooks covering options, flags, and file inputs
182
+ * `basics/` — small notebooks covering options, flags, file inputs, and status output
175
183
  * `custom_controls/` — `args.custom()` and mirroring it via `controls_from`
176
184
  * `composition/` — embedding and varying notebooks (`embed`, `variant_embed`)
177
185
  * `game_of_life/` — a worked multi-notebook example
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "moops"
7
- version = "0.13.2"
7
+ version = "0.13.4"
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"
@@ -31,11 +31,14 @@ dependencies = [
31
31
  test = ["hypothesis"]
32
32
  # Group.table() uses pandas.to_markdown(), which requires tabulate for CLI output.
33
33
  table = ["tabulate>=0.10.0"]
34
+ # Group.progress_bar() and Group.spinner() use tqdm on the CLI when installed.
35
+ status = ["tqdm>=4.67.1"]
34
36
 
35
37
  [project.urls]
36
38
  Homepage = "https://github.com/yairchu/moops"
37
39
  Repository = "https://github.com/yairchu/moops"
38
40
  Issues = "https://github.com/yairchu/moops/issues"
41
+ "Agent skill" = "https://github.com/yairchu/moops/tree/main/skills/migrate-marimo-to-moops"
39
42
 
40
43
  [dependency-groups]
41
44
  dev = [
@@ -47,6 +50,7 @@ dev = [
47
50
  "pylint>=3.0",
48
51
  "pymarkdownlnt>=0.9.37",
49
52
  "matplotlib>=3.10.9",
53
+ "tqdm>=4.67.1",
50
54
  "xarray>=2025.6.1",
51
55
  ]
52
56
 
@@ -81,6 +81,13 @@ def _build_list_ui(
81
81
  if 0 <= idx < len(current):
82
82
  set_items([*current[:idx], *current[idx + 1 :]])
83
83
 
84
+ def duplicate_at(idx: int) -> None:
85
+ current = current_items()
86
+ if 0 <= idx < len(current):
87
+ set_items(
88
+ [*current[: idx + 1], copy.deepcopy(current[idx]), *current[idx + 1 :]]
89
+ )
90
+
84
91
  def move(idx: int, delta: int) -> None:
85
92
  current = current_items()
86
93
  target = idx + delta
@@ -97,6 +104,11 @@ def _build_list_ui(
97
104
  tooltip="Insert an item here",
98
105
  on_click=lambda _, idx=i: insert_at(idx),
99
106
  )
107
+ duplicate_btn = mo.ui.button(
108
+ label="⧉",
109
+ tooltip="Duplicate this item",
110
+ on_click=lambda _, idx=i: duplicate_at(idx),
111
+ )
100
112
  up_btn = mo.ui.button(
101
113
  label="↑",
102
114
  tooltip="Move up",
@@ -116,7 +128,7 @@ def _build_list_ui(
116
128
  on_click=lambda _, idx=i: remove_at(idx),
117
129
  )
118
130
  controls = mo.hstack(
119
- [insert_btn, up_btn, down_btn, item_remove_btn],
131
+ [insert_btn, duplicate_btn, up_btn, down_btn, item_remove_btn],
120
132
  justify="start",
121
133
  )
122
134
  rows.append(mo.vstack([controls, element]))
@@ -425,6 +425,24 @@ def _missing_path_error(value: str) -> ParseError | None:
425
425
  return None
426
426
 
427
427
 
428
+ def _file_browser_paths(value: typing.Any) -> list[str]:
429
+ if not value:
430
+ return []
431
+ if isinstance(value, str):
432
+ return [value]
433
+ if isinstance(value, pathlib.Path):
434
+ return [str(value)]
435
+ if isinstance(value, typing.Sequence):
436
+ items = typing.cast(typing.Sequence[object], value)
437
+ return [
438
+ str(item.path)
439
+ if isinstance(item, _ui_workarounds.FileBrowserFileInfo)
440
+ else str(item)
441
+ for item in items
442
+ ]
443
+ return [str(value)]
444
+
445
+
428
446
  @dataclasses.dataclass
429
447
  class FileControl(TextControl):
430
448
  def create_marimo_element(
@@ -474,6 +492,14 @@ class FileControl(TextControl):
474
492
  continue
475
493
  return result
476
494
 
495
+ def format_value(self, value: typing.Any) -> list[str]:
496
+ paths = _file_browser_paths(value)
497
+ return super().format_value(paths[0] if paths else "")
498
+
499
+ def format_query_value(self, value: typing.Any) -> str | None:
500
+ paths = _file_browser_paths(value)
501
+ return super().format_query_value(paths[0] if paths else "")
502
+
477
503
 
478
504
  @dataclasses.dataclass
479
505
  class MultiFileControl(ValueControl):
@@ -554,13 +580,13 @@ class MultiFileControl(ValueControl):
554
580
  return [line]
555
581
 
556
582
  def format_value(self, value: typing.Any) -> list[str]:
557
- values = list(value)
583
+ values = _file_browser_paths(value)
558
584
  if values == self.default:
559
585
  return []
560
586
  return [option_value_token(self.option, v) for v in values]
561
587
 
562
588
  def format_query_value(self, value: typing.Any) -> str | None:
563
- values = list(value)
589
+ values = _file_browser_paths(value)
564
590
  return None if values == self.default else json.dumps(values)
565
591
 
566
592
  def prompt_interactive(self, effective_default: typing.Any = _UNSET) -> list[str]:
@@ -0,0 +1,265 @@
1
+ from __future__ import annotations
2
+
3
+ import collections.abc
4
+ import itertools
5
+ import sys
6
+ import threading
7
+ import typing
8
+
9
+ try:
10
+ from tqdm.auto import tqdm
11
+ except ImportError:
12
+ tqdm = None # type: ignore[assignment]
13
+
14
+ S = typing.TypeVar("S")
15
+
16
+
17
+ class DisabledSpinner:
18
+ def __enter__(self) -> DisabledSpinner:
19
+ return self
20
+
21
+ def __exit__(
22
+ self,
23
+ exc_type: type[BaseException] | None,
24
+ exc_value: BaseException | None,
25
+ traceback: typing.Any,
26
+ ) -> None:
27
+ del exc_type, exc_value, traceback
28
+
29
+ def update(
30
+ self,
31
+ *,
32
+ title: str | None = None,
33
+ subtitle: str | None = None,
34
+ ) -> None:
35
+ del title, subtitle
36
+
37
+ def close(self) -> None:
38
+ pass
39
+
40
+
41
+ class ProgressBar(typing.Generic[S]):
42
+ def __init__(
43
+ self,
44
+ collection: (
45
+ collections.abc.Collection[S]
46
+ | collections.abc.Iterator[S]
47
+ | collections.abc.AsyncIterable[S]
48
+ | None
49
+ ) = None,
50
+ *,
51
+ title: str | None = None,
52
+ subtitle: str | None = None,
53
+ completion_title: str | None = None,
54
+ completion_subtitle: str | None = None,
55
+ total: int | None = None,
56
+ remove_on_exit: bool = False,
57
+ disabled: bool = False,
58
+ **kwargs: typing.Any,
59
+ ) -> None:
60
+ explicit_total = total is not None
61
+ if collection is not None and total is None:
62
+ if not isinstance(collection, collections.abc.Sized):
63
+ raise TypeError("Cannot determine length; pass total")
64
+ total = len(collection)
65
+ if total is None:
66
+ raise ValueError("total is required")
67
+ self._collection = collection
68
+ self._title = title
69
+ self._subtitle = subtitle
70
+ self._completion_title = completion_title
71
+ self._completion_subtitle = completion_subtitle
72
+ self._total = total
73
+ self._current = 0
74
+ self._step = (
75
+ collection.step if explicit_total and isinstance(collection, range) else 1
76
+ )
77
+ self._disabled = disabled
78
+ self._closed = False
79
+ self._bar = (
80
+ None
81
+ if tqdm is None
82
+ else tqdm(
83
+ total=total,
84
+ desc=self._desc(),
85
+ disable=disabled,
86
+ leave=not remove_on_exit,
87
+ )
88
+ )
89
+ if self._bar is None:
90
+ self._emit()
91
+
92
+ def __iter__(self) -> collections.abc.Iterator[S]:
93
+ if self._collection is None:
94
+ raise RuntimeError("progress_bar needs a collection to iterate")
95
+ if isinstance(self._collection, collections.abc.AsyncIterable):
96
+ raise RuntimeError("Use async for with async collections")
97
+ try:
98
+ for item in self._collection:
99
+ yield item
100
+ self.update(increment=self._step)
101
+ finally:
102
+ self.close()
103
+
104
+ async def __aiter__(self) -> collections.abc.AsyncIterator[S]:
105
+ if self._collection is None:
106
+ raise RuntimeError("progress_bar needs a collection to iterate")
107
+ if not isinstance(self._collection, collections.abc.AsyncIterable):
108
+ raise RuntimeError("Use for with sync collections")
109
+ try:
110
+ async for item in self._collection:
111
+ yield item
112
+ self.update()
113
+ finally:
114
+ self.close()
115
+
116
+ def __enter__(self) -> ProgressBar[S]:
117
+ return self
118
+
119
+ def __exit__(
120
+ self,
121
+ exc_type: type[BaseException] | None,
122
+ exc_value: BaseException | None,
123
+ traceback: typing.Any,
124
+ ) -> None:
125
+ del exc_type, exc_value, traceback
126
+ self.close()
127
+
128
+ def update(
129
+ self,
130
+ *,
131
+ increment: int = 1,
132
+ title: str | None = None,
133
+ subtitle: str | None = None,
134
+ ) -> None:
135
+ self._current += increment
136
+ self._title = self._title if title is None else title
137
+ self._subtitle = self._subtitle if subtitle is None else subtitle
138
+ if self._bar is not None:
139
+ self._bar.set_description_str(self._desc(), refresh=False)
140
+ self._bar.update(increment)
141
+ else:
142
+ self._emit()
143
+
144
+ def close(self) -> None:
145
+ if self._closed:
146
+ return
147
+ self._closed = True
148
+ if self._completion_title is not None or self._completion_subtitle is not None:
149
+ self.update(
150
+ increment=0,
151
+ title=self._completion_title,
152
+ subtitle=self._completion_subtitle,
153
+ )
154
+ if self._bar is not None:
155
+ self._bar.close()
156
+
157
+ def _desc(self) -> str:
158
+ label = self._title or "Progress"
159
+ detail = f" - {self._subtitle}" if self._subtitle else ""
160
+ return f"{label}{detail}"
161
+
162
+ def _emit(self) -> None:
163
+ if not self._disabled:
164
+ print(f"{self._desc()}: {self._current}/{self._total}")
165
+
166
+
167
+ class Spinner:
168
+ def __init__(
169
+ self,
170
+ title: str | None = None,
171
+ subtitle: str | None = None,
172
+ remove_on_exit: bool = True,
173
+ *,
174
+ disabled: bool = False,
175
+ ) -> None:
176
+ self._title = title
177
+ self._subtitle = subtitle
178
+ self._remove_on_exit = remove_on_exit
179
+ self._disabled = disabled
180
+ self._stream = sys.stderr
181
+ self._closed = False
182
+ self._rendered_len = 0
183
+ self._frames = itertools.cycle("-\\|/")
184
+ self._stop = threading.Event()
185
+ self._thread: threading.Thread | None = None
186
+ if self._can_animate():
187
+ self._render_frame(next(self._frames))
188
+ self._thread = threading.Thread(target=self._animate, daemon=True)
189
+ self._thread.start()
190
+ else:
191
+ self._emit()
192
+
193
+ def __enter__(self) -> Spinner:
194
+ return self
195
+
196
+ def __exit__(
197
+ self,
198
+ exc_type: type[BaseException] | None,
199
+ exc_value: BaseException | None,
200
+ traceback: typing.Any,
201
+ ) -> None:
202
+ del exc_type, exc_value, traceback
203
+ self.close()
204
+
205
+ def close(self) -> None:
206
+ if self._closed:
207
+ return
208
+ self._closed = True
209
+ if self._thread is not None:
210
+ self._stop.set()
211
+ self._thread.join(timeout=0.5)
212
+ if self._remove_on_exit:
213
+ self._clear_line()
214
+ else:
215
+ self._write_line(self._desc(), newline=True)
216
+ return
217
+ if not self._remove_on_exit:
218
+ print("Done")
219
+
220
+ def update(
221
+ self,
222
+ *,
223
+ title: str | None = None,
224
+ subtitle: str | None = None,
225
+ ) -> None:
226
+ self._title = self._title if title is None else title
227
+ self._subtitle = self._subtitle if subtitle is None else subtitle
228
+ if self._thread is not None:
229
+ self._render_frame(next(self._frames))
230
+ else:
231
+ self._emit()
232
+
233
+ def _desc(self) -> str:
234
+ label = self._title or "Working"
235
+ detail = f" - {self._subtitle}" if self._subtitle else ""
236
+ return f"{label}{detail}"
237
+
238
+ def _emit(self) -> None:
239
+ if not self._disabled:
240
+ print(self._desc())
241
+
242
+ def _can_animate(self) -> bool:
243
+ return (
244
+ not self._disabled
245
+ and hasattr(self._stream, "isatty")
246
+ and self._stream.isatty()
247
+ )
248
+
249
+ def _animate(self) -> None:
250
+ while not self._stop.wait(0.1):
251
+ self._render_frame(next(self._frames))
252
+
253
+ def _render_frame(self, frame: str) -> None:
254
+ self._write_line(f"{frame} {self._desc()}")
255
+
256
+ def _write_line(self, text: str, *, newline: bool = False) -> None:
257
+ padded = text.ljust(self._rendered_len)
258
+ self._stream.write("\r" + padded + ("\n" if newline else ""))
259
+ self._stream.flush()
260
+ self._rendered_len = max(self._rendered_len, len(text))
261
+
262
+ def _clear_line(self) -> None:
263
+ if self._rendered_len:
264
+ self._stream.write("\r" + (" " * self._rendered_len) + "\r")
265
+ self._stream.flush()
@@ -44,14 +44,16 @@ def wrap_help_line(line: str, width: int = 88) -> list[str]:
44
44
  return result
45
45
 
46
46
 
47
- def wrap_command(name: str, groups: list[str], width: int = 72) -> str:
47
+ def wrap_command(
48
+ name: str, groups: list[str], width: int = 72, *, quote_name: bool = True
49
+ ) -> str:
48
50
  """Render a script command, wrapping long lines with shell continuations.
49
51
 
50
52
  Short commands stay on one line. When the single-line form exceeds ``width``,
51
53
  each option group goes on its own line joined by `` \\`` continuations, which
52
54
  remains valid copy-pasteable shell.
53
55
  """
54
- quoted_name = shlex.quote(name)
56
+ quoted_name = shlex.quote(name) if quote_name else name
55
57
  single_line = " ".join([quoted_name, *groups])
56
58
  if not groups or len(single_line) <= width:
57
59
  return single_line
@@ -21,6 +21,7 @@ from . import (
21
21
  _parse,
22
22
  _preset_state,
23
23
  _query_params,
24
+ _status,
24
25
  _terminal_graphics,
25
26
  _value_resolution,
26
27
  _variant,
@@ -33,6 +34,7 @@ from ._ui_workarounds import FileBrowserWithInitialSelection
33
34
  from .presets import Presets
34
35
 
35
36
  Numeric = int | float
37
+ S = typing.TypeVar("S")
36
38
 
37
39
 
38
40
  class OutputMode(enum.Enum):
@@ -410,6 +412,47 @@ class Group:
410
412
  print()
411
413
  return None
412
414
 
415
+ def progress_bar(
416
+ self,
417
+ collection: typing.Collection[S]
418
+ | typing.Iterator[S]
419
+ | typing.AsyncIterable[S]
420
+ | None = None,
421
+ *,
422
+ disabled: bool = False,
423
+ **kwargs: typing.Any,
424
+ ) -> typing.Any:
425
+ """Show progress in notebooks or plain status lines on the CLI."""
426
+ return typing.cast(
427
+ typing.Any,
428
+ mo.status.progress_bar
429
+ if self.output_mode is OutputMode.NOTEBOOK
430
+ else _status.ProgressBar,
431
+ )(
432
+ collection,
433
+ disabled=disabled or self.is_interface_query or self.output_mode is None,
434
+ **kwargs,
435
+ )
436
+
437
+ def spinner(
438
+ self,
439
+ title: str | None = None,
440
+ subtitle: str | None = None,
441
+ remove_on_exit: bool = True,
442
+ ) -> typing.Any:
443
+ """Show a spinner in notebooks or a plain status line on the CLI."""
444
+ if self.is_interface_query or self.output_mode is None:
445
+ return _status.DisabledSpinner()
446
+ return (
447
+ mo.status.spinner
448
+ if self.output_mode is OutputMode.NOTEBOOK
449
+ else _status.Spinner
450
+ )(
451
+ title=title,
452
+ subtitle=subtitle,
453
+ remove_on_exit=remove_on_exit,
454
+ )
455
+
413
456
  def _bool_control(
414
457
  self,
415
458
  widget: typing.Literal["switch", "checkbox"],
@@ -436,19 +436,22 @@ class Interface:
436
436
  if not self.notebook_name:
437
437
  return mo.md("Input bundle with no notebook name")
438
438
  notebook_name = html.escape(self.notebook_name)
439
- href = html.escape(self._standalone_url(), quote=True)
440
- link = mo.md(
441
- f'<a href="{href}" target="_blank" rel="noopener">'
442
- f"An embedded instance of `{notebook_name}`</a>"
443
- )
439
+ label = f"An embedded instance of `{notebook_name}`"
440
+ if _running_in_edit_mode():
441
+ href = html.escape(self._standalone_url(), quote=True)
442
+ heading: mo.Html = mo.md(
443
+ f'<a href="{href}" target="_blank" rel="noopener">{label}</a>'
444
+ )
445
+ else:
446
+ heading = mo.md(label)
444
447
  if self.embedded_extra_overrides != frozenset():
445
448
  # None: embedded outside moops.embed, so overrides are unknown.
446
449
  # Non-empty: the parent injected defs beyond args, so no CLI
447
450
  # command can reproduce this setup.
448
- return link
451
+ return heading
449
452
  command = _wrap_command(self.notebook_file, self._standalone_arg_groups())
450
453
  return mo.vstack(
451
- [link, mo.md(f"To run as a standalone script:\n```\n{command}\n```")]
454
+ [heading, mo.md(f"To run as a standalone script:\n```\n{command}\n```")]
452
455
  )
453
456
 
454
457
  def _standalone_url(self) -> str:
@@ -633,7 +636,9 @@ def attached_interface(ctrl: typing.Any) -> Interface | None:
633
636
 
634
637
  def _wrap_command(command: str, groups: list[str]) -> str:
635
638
  if _should_use_uv_run(command):
636
- return _text_wrap.wrap_command("uv", [f"run {shlex.quote(command)}", *groups])
639
+ return _text_wrap.wrap_command(
640
+ f"uv run {shlex.quote(command)}", groups, quote_name=False
641
+ )
637
642
  return _text_wrap.wrap_command(command, groups)
638
643
 
639
644
 
@@ -716,3 +721,25 @@ def _collect_usage_placeholders(
716
721
  for ctrl in iface.controls:
717
722
  if sub_iface := attached_interface(ctrl):
718
723
  _collect_usage_placeholders(sub_iface, result)
724
+
725
+
726
+ def _running_in_edit_mode() -> bool:
727
+ """Return True only when running inside a marimo *edit* session.
728
+
729
+ ``mo.running_in_notebook()`` is True for both ``marimo edit`` and
730
+ ``marimo run``, but links to ``/?file=...`` only work in edit sessions.
731
+ """
732
+ if not mo.running_in_notebook():
733
+ return False
734
+ try:
735
+ from marimo._runtime.context import get_context
736
+ from marimo._runtime.context.kernel_context import KernelRuntimeContext
737
+ from marimo._session.model import SessionMode
738
+
739
+ ctx = get_context()
740
+ return (
741
+ isinstance(ctx, KernelRuntimeContext)
742
+ and ctx.session_mode == SessionMode.EDIT
743
+ )
744
+ except Exception:
745
+ return False
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes