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

1"""The `jointview` command: start marimo on the app that ships with this package.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import subprocess # nosec B404 # launching marimo is what this module is for 

7import sys 

8from pathlib import Path 

9 

10APP = Path(__file__).with_name("app.py") 

11 

12 

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 

46 

47 

48def _split(argv: list[str]) -> tuple[list[str], list[str]]: 

49 """Cut ``argv`` at the first bare ``--`` into our arguments and marimo's. 

50 

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. 

55 

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 :] 

65 

66 

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()``. 

69 

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 

94 

95 

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)) 

99 

100 parser = _parser() 

101 args = parser.parse_args(ours) 

102 app_args = _app_args(args, parser) 

103 

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] 

119 

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