Coverage for src/jointview/app.py: 100%

67 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-10-01 04:48 +0000

1"""The marimo notebook itself: two pickers, one chart, a summary table either side. 

2 

3Run it with the ``jointview`` command rather than opening this file — see 

4:mod:`jointview.cli`, which starts marimo on this notebook and passes it ``--data`` 

5and ``--height``. 

6""" 

7 

8# The cell signatures below are marimo's, not ours: it names every cell ``_``, derives 

9# the parameters from what the cell reads, and rewrites both on save. Annotating them 

10# would be annotating generated code that the next edit in the marimo editor throws 

11# away — and the types are marimo's UI plumbing, so the annotations would be `Any` 

12# anyway. Told to ruff here rather than in a config file, so the reason sits next to 

13# the code it excuses. (ty asks for none of this; it does not require annotations.) 

14# 

15# N803 is there for the same reason. A cell's parameters *are* the names it reads, so a 

16# cell reading a module-level constant — `WINDOWS`, `WINDOW_ALL` — takes an upper-case 

17# argument. That is marimo spelling a constant's own name back, not an argument named 

18# against the convention here, and lower-casing it would mean aliasing the constant to 

19# hide the shape of the generated code. 

20# ruff: noqa: ANN001, ANN202, N803 

21# 

22# The `return` closing each cell is marimo's too, and it is why four of them below carry 

23# a `# pragma: no cover`. Running the notebook never calls these functions: marimo keeps 

24# each cell as its body alone — the stored source parses to Import, Expr, Assign or 

25# FunctionDef, never Return — and executes that as two code objects, the body exec'd and 

26# a final expression eval'd to become the cell's output. So no `return` here is ever 

27# reached, by `app.run()` or by anything else. 

28# 

29# Which makes the interesting case the five that carry no pragma. They are not better 

30# tested; they are shadowed. Where a cell ends in a statement rather than an expression, 

31# marimo emits its synthetic end-of-cell expression onto the next line down, and for a 

32# one-line last statement that line is the `return`. Coverage sees bytecode there and 

33# marks it hit. Nothing to fix — but the four pragmas are the honest cells, not the 

34# neglected ones, and removing them would report a reach that `tests/test_app.py` does 

35# not have. 

36 

37import marimo 

38 

39__generated_with = "0.23.15" 

40app = marimo.App(width="full") 

41 

42 

43@app.cell 

44def _(): 

45 """Import marimo — the one cell every other cell here depends on.""" 

46 import marimo as mo 

47 

48 return (mo,) # pragma: no cover 

49 

50 

51@app.cell 

52def _(mo) -> None: 

53 """Give the plot back the margins marimo reserves for prose.""" 

54 # marimo pads a notebook for prose — 96px of side margin and 48px under the last 

55 # cell, twice over. This is a single-screen instrument, so those margins go back to 

56 # the plot. First cell so the trim is in the DOM before anything that sizes itself 

57 # against the page — the chart follows its container's width — is laid out. 

58 # 

59 # Matched on class *substrings* because the classes are Tailwind and carry colons; 

60 # if a future marimo renames them the rules stop applying and the app still lays 

61 # out, only roomier. 

62 mo.Html(""" 

63 <style> 

64 #App [class*="xl:px-24"] { padding-inline: 0.75rem !important; } 

65 #App [class*="pb-24"] { padding-bottom: 0 !important; } 

66 #App .output-area { padding-inline: 0 !important; } 

67 /* vega-embed keeps a further 38px on the right for its own "..." menu. That one 

68 is out of reach — marimo renders the chart into a shadow root — and it buys 

69 the save/view-source menu, so it stays. */ 

70 </style> 

71 """) 

72 return # pragma: no cover 

73 

74 

75@app.cell 

76def _(mo): 

77 """Load the frame, and settle the two things the command line gets a say in.""" 

78 from jointview.columns import ( 

79 WINDOW_ALL, 

80 WINDOWS, 

81 aligned, 

82 date_column, 

83 default_pair, 

84 series_columns, 

85 windowed, 

86 ) 

87 from jointview.data import load_frame 

88 from jointview.plot import drawn_points, line_chart 

89 from jointview.stats import summary_markdown 

90 

91 # jointview navs.parquet --height 900 

92 frame = load_frame(mo.cli_args().get("data")) 

93 names = series_columns(frame) 

94 # Nothing on the page has a height for the plot to follow, so it is a number. 700 

95 # fills a laptop window once the notebook margins are gone; a tall monitor wants 

96 # more, and only the person looking at it knows which they have. 

97 plot_height = int(mo.cli_args().get("height") or 700) 

98 return ( 

99 WINDOWS, 

100 WINDOW_ALL, 

101 aligned, 

102 date_column, 

103 default_pair, 

104 drawn_points, 

105 frame, 

106 line_chart, 

107 names, 

108 plot_height, 

109 summary_markdown, 

110 windowed, 

111 ) 

112 

113 

114@app.cell 

115def _(default_pair, frame, mo, names): 

116 """Build the two series pickers, opened on a pair worth looking at.""" 

117 a_start, b_start = default_pair(frame) 

118 

119 # One dropdown is the whole selector: it holds its own value, so there is no 

120 # mo.state and no two-way binding to keep in step. It also costs a fixed two lines 

121 # of the panel whatever the frame is — a radio list grew with the column count, 

122 # and a wide parquet would have turned the side panels into a scroll. 

123 def _pick(start): 

124 """One dropdown over every series, opened on the column at ``start``.""" 

125 return mo.ui.dropdown( 

126 options=names, 

127 value=names[start], 

128 # Searchable from ten columns up, where scanning the list stops being 

129 # quicker than typing. Below that the search box is only in the way. 

130 searchable=len(names) >= 10, 

131 # There is no such thing as a plot of no series: the "--" entry would only 

132 # ever be a way to break the chart. 

133 allow_select_none=False, 

134 full_width=True, 

135 ) 

136 

137 a_pick, b_pick = _pick(a_start), _pick(b_start) 

138 return a_pick, b_pick 

139 

140 

141@app.cell 

142def _(WINDOWS, WINDOW_ALL, date_column, frame, mo): 

143 """The two controls over the plot: how much of the sample to draw, and whether to index.""" 

144 # Two NAVs on one axis only mean something on a shared scale; off, the raw levels 

145 # are there for a pair that already shares one. 

146 rebase = mo.ui.switch(value=True, label="index both to 100") 

147 

148 # A radio rather than a date-range picker, and one row rather than two boxes: the 

149 # questions actually asked of a NAV curve are "this year" and "the last three years", 

150 # and a chip answers them in a click. Exclusive by construction, which is what the 

151 # sample being one stretch of calendar requires — the whole of it by default, because 

152 # a file's own span is the only window nobody has to have chosen. 

153 # 

154 # Cut by date, so a frame numbered by row has nothing to cut against and is offered 

155 # the whole sample alone. Three chips that quietly did nothing would be worse than 

156 # their absence. 

157 windows = list(WINDOWS) if date_column(frame) else [WINDOW_ALL] 

158 window = mo.ui.radio(options=windows, value=WINDOW_ALL, inline=True) 

159 return rebase, window 

160 

161 

162@app.cell 

163def _(a_pick, aligned, b_pick, frame, line_chart, plot_height, rebase, window, windowed): 

164 """Read the controls, and derive everything the layout below draws.""" 

165 a_column = a_pick.value 

166 b_column = b_pick.value 

167 # The window is taken once, here, and both the chart and the tables are built from 

168 # what it leaves — which is what keeps the caption's promise under a cut. Rebasing 

169 # follows it too: 100 lands at the start of the window, which is the comparison 

170 # choosing a window asks for. 

171 shown = windowed(frame, window.value) 

172 pair = aligned(shown, a_column, b_column) 

173 chart = line_chart(shown, a_column, b_column, rebase=rebase.value, height=plot_height) 

174 return a_column, b_column, chart, pair 

175 

176 

177@app.cell 

178def _(WINDOW_ALL, drawn_points, mo, summary_markdown): 

179 """The furniture the page is assembled from: the panel pieces, and the caption.""" 

180 

181 def summary_table(pair, side, title): 

182 """A metric table for one side of ``pair``, or a note when the sample is too short. 

183 

184 The whole aligned frame goes in rather than the column alone: jQuantStats reads 

185 the annualisation factor off the spacing of the period column, so the dates have 

186 to travel with the levels. 

187 """ 

188 try: 

189 return mo.md(summary_markdown(pair, side, title=title)) 

190 except (ValueError, KeyError): 

191 return mo.md(f"**{title}** — too few overlapping observations to summarise.") 

192 

193 # A dropdown and seventeen metrics: the panel is now the same height whatever the 

194 # frame holds, so it no longer needs the scroll box that a per-column radio list 

195 # did. The wide gap keeps the split legible — the dropdown is a control, the 

196 # table under it is a result, and they should not read as one column of text. 

197 def panel(label, picker, table): 

198 """One side of the page: a heading, its picker, and the table underneath.""" 

199 return mo.vstack( 

200 [ 

201 mo.md(f"**{label}**"), 

202 mo.vstack([picker, table], gap=1.75), 

203 ], 

204 gap=0.5, 

205 ) 

206 

207 def caption(a_column, b_column, frame, pair, window=WINDOW_ALL): 

208 """One line under the plot saying what it is of, and what the tables describe. 

209 

210 Two numbers, and past `plot.MAX_POINTS` they differ: the curve is thinned for the 

211 browser's sake while the tables go on describing every date in the common sample. 

212 Saying so is what keeps this a caption rather than a claim — a max drawdown in a 

213 table can sit at a date the line no longer carries a point for. 

214 

215 The window is named for the same reason. `frame` is the whole file whatever is 

216 selected, so under a cut the dates missing from the sample were not dates a series 

217 was absent on — they were dates left outside the window, and "252 of 1,500 dates 

218 where both series are present" would report a choice as a hole in the data. 

219 """ 

220 dropped = frame.height - pair.height 

221 note = f" of {frame.height:,}" if dropped else "" 

222 inside = "" if window == WINDOW_ALL else f" inside `{window}`" 

223 sample = f"{pair.height:,}{note} dates{inside} where both series are present" 

224 drawn = drawn_points(pair.height) 

225 body = ( 

226 f"{drawn:,} points drawn from {sample}; the tables summarise all {pair.height:,}" 

227 if drawn < pair.height 

228 else f"{sample}, which is also what the tables summarise" 

229 ) 

230 # The multiplication sign is the character this means; a lowercase x beside two 

231 # column names reads as part of one of them. 

232 return mo.md(f"`{a_column}` × `{b_column}` — {body}.") # noqa: RUF001 

233 

234 return caption, panel, summary_table # pragma: no cover 

235 

236 

237@app.cell 

238def _(a_column, b_column, caption, chart, frame, mo, pair, rebase, window): 

239 """The middle column: the controls, the chart, and a line saying what it is of.""" 

240 # The two controls share one row above the plot: both are about the picture rather 

241 # than about either series, and neither is wide enough to spend a line of the page on. 

242 # Pushed to the two ends of that row, which is the chart's own width — the windows 

243 # under the y-axis on the left, the switch over the end of the lines on the right — 

244 # so the row reads as the plot's own frame rather than as a caption above it. Nothing 

245 # sits outside those limits: the hstack is the chart's container, so "space-between" 

246 # can only reach the edges the picture already occupies. 

247 controls = mo.hstack([window, rebase], justify="space-between", align="center", gap=1.5) 

248 # No align= here: centring would shrink the stack to its content and hand the chart 

249 # back the gutter that "container" is there to fill. 

250 figure = mo.vstack( 

251 [controls, chart, caption(a_column, b_column, frame, pair, window.value)], 

252 gap=0.25, 

253 ) 

254 return (figure,) 

255 

256 

257@app.cell 

258def _(a_column, a_pick, b_column, b_pick, figure, mo, pair, panel, summary_table) -> None: 

259 """Lay out the page: a picker and its table either side of the figure.""" 

260 mo.hstack( 

261 [ 

262 panel("left", a_pick, summary_table(pair, "a", a_column)), 

263 figure, 

264 panel("right", b_pick, summary_table(pair, "b", b_column)), 

265 ], 

266 # The side panels hold a dropdown and a two-column table; anything wider than 

267 # that is width taken off the picture. 

268 widths=[1, 6, 1], 

269 align="start", 

270 gap=0.75, 

271 ) 

272 return # pragma: no cover 

273 

274 

275if __name__ == "__main__": 

276 app.run()