Coverage for src/jointview/cli.py: 100%
43 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 06:44 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 06:44 +0000
1"""The `jointview` command: start marimo on the app that ships with this package."""
3from __future__ import annotations
5import argparse
6import subprocess # nosec B404 # launching marimo is what this module is for
7import sys
8from pathlib import Path
10APP = Path(__file__).with_name("app.py")
13def _parser() -> argparse.ArgumentParser:
14 """The app's own arguments — everything marimo's command line does not own."""
15 parser = argparse.ArgumentParser(
16 prog="jointview",
17 description="Compare two price or NAV series of a table, side by side.",
18 epilog=(
19 "Arguments after a bare -- go to marimo untouched, for the server itself: "
20 "jointview navs.parquet -- --port 8080 --headless"
21 ),
22 )
23 parser.add_argument(
24 "data",
25 nargs="?",
26 help=(
27 "table to read: .parquet, .csv, .tsv, .json, .ndjson, .arrow, .ipc or "
28 ".feather. Omitted, you get the generated demo frame."
29 ),
30 )
31 # The notebook's own option is spelled --data, and that is what this took back when
32 # it was `marimo run app.py -- --data ...`. Kept as a hidden alias of the
33 # positional, so the old line still runs.
34 parser.add_argument("--data", dest="data_flag", help=argparse.SUPPRESS)
35 parser.add_argument(
36 "--height",
37 type=int,
38 help="plot height in pixels (default 700, which suits a laptop window).",
39 )
40 parser.add_argument(
41 "--edit",
42 action="store_true",
43 help="open the notebook itself instead of running it.",
44 )
45 return parser
48def _split(argv: list[str]) -> tuple[list[str], list[str]]:
49 """Cut ``argv`` at the first bare ``--`` into our arguments and marimo's.
51 Done before argparse sees any of it, rather than sweeping up the leftovers of
52 parse_known_args: with an optional positional in the grammar, an unrecognised
53 `--port 8080` loses its 8080 to `data`, and the mistake only surfaces as marimo
54 complaining about a flag the user never typed.
56 >>> _split(["navs.parquet", "--", "--port", "8080"])
57 (['navs.parquet'], ['--port', '8080'])
58 >>> _split(["navs.parquet"])
59 (['navs.parquet'], [])
60 """
61 if "--" not in argv:
62 return argv, []
63 cut = argv.index("--")
64 return argv[:cut], argv[cut + 1 :]
67def _app_args(args: argparse.Namespace, parser: argparse.ArgumentParser) -> list[str]:
68 """The notebook's own options, as the command line it reads off ``mo.cli_args()``.
70 That is everything after the `--` in marimo's command line — so ours and marimo's
71 swap sides here.
72 """
73 app_args: list[str] = []
74 # Both spellings at once is a confusion rather than a preference. The alias is
75 # hidden, so whoever typed it alongside the positional cannot know which of the two
76 # files is about to open — and silently picking one would open the wrong data under
77 # a page that gives no sign of it. Reported here for the same reason a missing file
78 # is: before the browser tab, not after.
79 if args.data_flag and args.data:
80 parser.error(f"give the file once: {args.data} as the argument, {args.data_flag} after --data")
81 data = args.data_flag or args.data
82 if data:
83 # Absolute, because the path was typed relative to the shell and the notebook
84 # it is bound for lives in a wheel somewhere under the uv cache. Checked here
85 # too: a missing file should be a line from the shell, not a traceback in a
86 # cell of a notebook that has already opened a browser tab.
87 file = Path(data).expanduser().resolve()
88 if not file.exists():
89 parser.error(f"no such file: {data}")
90 app_args += ["--data", str(file)]
91 if args.height:
92 app_args += ["--height", str(args.height)]
93 return app_args
96def main(argv: list[str] | None = None) -> int:
97 """Run `marimo run` (or `edit`) on the packaged notebook, and return its exit code."""
98 ours, marimo_args = _split(list(sys.argv[1:] if argv is None else argv))
100 parser = _parser()
101 args = parser.parse_args(ours)
102 app_args = _app_args(args, parser)
104 # `python -m marimo`, not a bare `marimo`: under uvx the two need not be the same
105 # interpreter, and only this one is sure to have jointview importable — which the
106 # notebook needs on its first cell.
107 command = [
108 sys.executable,
109 "-m",
110 "marimo",
111 "edit" if args.edit else "run",
112 # In front of the notebook path, where `marimo run [OPTIONS] NAME` wants them;
113 # after it they would be read as arguments to the notebook.
114 *marimo_args,
115 str(APP),
116 ]
117 if app_args:
118 command += ["--", *app_args]
120 try:
121 # No shell, and argv is a list, so nothing here is word-split or glob-expanded:
122 # the executable is this interpreter, the notebook path is the installed
123 # wheel's, and the rest are the user's own arguments on their own machine —
124 # the same ones they would have typed after `marimo run`.
125 return subprocess.call(command) # noqa: S603 # nosec B603
126 except KeyboardInterrupt:
127 # Ctrl-C reached the child too; it has already said its goodbyes.
128 return 130